plain 0.64.0__tar.gz → 0.65.0__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 (181) hide show
  1. {plain-0.64.0 → plain-0.65.0}/PKG-INFO +1 -1
  2. {plain-0.64.0 → plain-0.65.0}/plain/CHANGELOG.md +10 -0
  3. plain-0.65.0/plain/http/hosts.py +147 -0
  4. {plain-0.64.0 → plain-0.65.0}/plain/http/request.py +2 -47
  5. {plain-0.64.0 → plain-0.65.0}/plain/runtime/global_settings.py +1 -0
  6. {plain-0.64.0 → plain-0.65.0}/plain/utils/http.py +0 -20
  7. {plain-0.64.0 → plain-0.65.0}/pyproject.toml +1 -1
  8. plain-0.65.0/tests/test_http_hosts.py +214 -0
  9. {plain-0.64.0 → plain-0.65.0}/.gitignore +0 -0
  10. {plain-0.64.0 → plain-0.65.0}/LICENSE +0 -0
  11. {plain-0.64.0 → plain-0.65.0}/README.md +0 -0
  12. {plain-0.64.0 → plain-0.65.0}/plain/AGENTS.md +0 -0
  13. {plain-0.64.0 → plain-0.65.0}/plain/README.md +0 -0
  14. {plain-0.64.0 → plain-0.65.0}/plain/__main__.py +0 -0
  15. {plain-0.64.0 → plain-0.65.0}/plain/assets/README.md +0 -0
  16. {plain-0.64.0 → plain-0.65.0}/plain/assets/__init__.py +0 -0
  17. {plain-0.64.0 → plain-0.65.0}/plain/assets/compile.py +0 -0
  18. {plain-0.64.0 → plain-0.65.0}/plain/assets/finders.py +0 -0
  19. {plain-0.64.0 → plain-0.65.0}/plain/assets/fingerprints.py +0 -0
  20. {plain-0.64.0 → plain-0.65.0}/plain/assets/urls.py +0 -0
  21. {plain-0.64.0 → plain-0.65.0}/plain/assets/views.py +0 -0
  22. {plain-0.64.0 → plain-0.65.0}/plain/chores/README.md +0 -0
  23. {plain-0.64.0 → plain-0.65.0}/plain/chores/__init__.py +0 -0
  24. {plain-0.64.0 → plain-0.65.0}/plain/chores/registry.py +0 -0
  25. {plain-0.64.0 → plain-0.65.0}/plain/cli/README.md +0 -0
  26. {plain-0.64.0 → plain-0.65.0}/plain/cli/__init__.py +0 -0
  27. {plain-0.64.0 → plain-0.65.0}/plain/cli/agent/__init__.py +0 -0
  28. {plain-0.64.0 → plain-0.65.0}/plain/cli/agent/docs.py +0 -0
  29. {plain-0.64.0 → plain-0.65.0}/plain/cli/agent/llmdocs.py +0 -0
  30. {plain-0.64.0 → plain-0.65.0}/plain/cli/agent/md.py +0 -0
  31. {plain-0.64.0 → plain-0.65.0}/plain/cli/agent/prompt.py +0 -0
  32. {plain-0.64.0 → plain-0.65.0}/plain/cli/agent/request.py +0 -0
  33. {plain-0.64.0 → plain-0.65.0}/plain/cli/build.py +0 -0
  34. {plain-0.64.0 → plain-0.65.0}/plain/cli/changelog.py +0 -0
  35. {plain-0.64.0 → plain-0.65.0}/plain/cli/chores.py +0 -0
  36. {plain-0.64.0 → plain-0.65.0}/plain/cli/core.py +0 -0
  37. {plain-0.64.0 → plain-0.65.0}/plain/cli/docs.py +0 -0
  38. {plain-0.64.0 → plain-0.65.0}/plain/cli/formatting.py +0 -0
  39. {plain-0.64.0 → plain-0.65.0}/plain/cli/install.py +0 -0
  40. {plain-0.64.0 → plain-0.65.0}/plain/cli/output.py +0 -0
  41. {plain-0.64.0 → plain-0.65.0}/plain/cli/preflight.py +0 -0
  42. {plain-0.64.0 → plain-0.65.0}/plain/cli/print.py +0 -0
  43. {plain-0.64.0 → plain-0.65.0}/plain/cli/registry.py +0 -0
  44. {plain-0.64.0 → plain-0.65.0}/plain/cli/scaffold.py +0 -0
  45. {plain-0.64.0 → plain-0.65.0}/plain/cli/settings.py +0 -0
  46. {plain-0.64.0 → plain-0.65.0}/plain/cli/shell.py +0 -0
  47. {plain-0.64.0 → plain-0.65.0}/plain/cli/startup.py +0 -0
  48. {plain-0.64.0 → plain-0.65.0}/plain/cli/upgrade.py +0 -0
  49. {plain-0.64.0 → plain-0.65.0}/plain/cli/urls.py +0 -0
  50. {plain-0.64.0 → plain-0.65.0}/plain/cli/utils.py +0 -0
  51. {plain-0.64.0 → plain-0.65.0}/plain/csrf/README.md +0 -0
  52. {plain-0.64.0 → plain-0.65.0}/plain/csrf/middleware.py +0 -0
  53. {plain-0.64.0 → plain-0.65.0}/plain/csrf/views.py +0 -0
  54. {plain-0.64.0 → plain-0.65.0}/plain/debug.py +0 -0
  55. {plain-0.64.0 → plain-0.65.0}/plain/exceptions.py +0 -0
  56. {plain-0.64.0 → plain-0.65.0}/plain/forms/README.md +0 -0
  57. {plain-0.64.0 → plain-0.65.0}/plain/forms/__init__.py +0 -0
  58. {plain-0.64.0 → plain-0.65.0}/plain/forms/boundfield.py +0 -0
  59. {plain-0.64.0 → plain-0.65.0}/plain/forms/exceptions.py +0 -0
  60. {plain-0.64.0 → plain-0.65.0}/plain/forms/fields.py +0 -0
  61. {plain-0.64.0 → plain-0.65.0}/plain/forms/forms.py +0 -0
  62. {plain-0.64.0 → plain-0.65.0}/plain/http/README.md +0 -0
  63. {plain-0.64.0 → plain-0.65.0}/plain/http/__init__.py +0 -0
  64. {plain-0.64.0 → plain-0.65.0}/plain/http/cookie.py +0 -0
  65. {plain-0.64.0 → plain-0.65.0}/plain/http/multipartparser.py +0 -0
  66. {plain-0.64.0 → plain-0.65.0}/plain/http/response.py +0 -0
  67. {plain-0.64.0 → plain-0.65.0}/plain/internal/__init__.py +0 -0
  68. {plain-0.64.0 → plain-0.65.0}/plain/internal/files/__init__.py +0 -0
  69. {plain-0.64.0 → plain-0.65.0}/plain/internal/files/base.py +0 -0
  70. {plain-0.64.0 → plain-0.65.0}/plain/internal/files/locks.py +0 -0
  71. {plain-0.64.0 → plain-0.65.0}/plain/internal/files/move.py +0 -0
  72. {plain-0.64.0 → plain-0.65.0}/plain/internal/files/temp.py +0 -0
  73. {plain-0.64.0 → plain-0.65.0}/plain/internal/files/uploadedfile.py +0 -0
  74. {plain-0.64.0 → plain-0.65.0}/plain/internal/files/uploadhandler.py +0 -0
  75. {plain-0.64.0 → plain-0.65.0}/plain/internal/files/utils.py +0 -0
  76. {plain-0.64.0 → plain-0.65.0}/plain/internal/handlers/__init__.py +0 -0
  77. {plain-0.64.0 → plain-0.65.0}/plain/internal/handlers/base.py +0 -0
  78. {plain-0.64.0 → plain-0.65.0}/plain/internal/handlers/exception.py +0 -0
  79. {plain-0.64.0 → plain-0.65.0}/plain/internal/handlers/wsgi.py +0 -0
  80. {plain-0.64.0 → plain-0.65.0}/plain/internal/middleware/__init__.py +0 -0
  81. {plain-0.64.0 → plain-0.65.0}/plain/internal/middleware/headers.py +0 -0
  82. {plain-0.64.0 → plain-0.65.0}/plain/internal/middleware/https.py +0 -0
  83. {plain-0.64.0 → plain-0.65.0}/plain/internal/middleware/slash.py +0 -0
  84. {plain-0.64.0 → plain-0.65.0}/plain/json.py +0 -0
  85. {plain-0.64.0 → plain-0.65.0}/plain/logs/README.md +0 -0
  86. {plain-0.64.0 → plain-0.65.0}/plain/logs/__init__.py +0 -0
  87. {plain-0.64.0 → plain-0.65.0}/plain/logs/configure.py +0 -0
  88. {plain-0.64.0 → plain-0.65.0}/plain/logs/debug.py +0 -0
  89. {plain-0.64.0 → plain-0.65.0}/plain/logs/formatters.py +0 -0
  90. {plain-0.64.0 → plain-0.65.0}/plain/logs/loggers.py +0 -0
  91. {plain-0.64.0 → plain-0.65.0}/plain/logs/utils.py +0 -0
  92. {plain-0.64.0 → plain-0.65.0}/plain/packages/README.md +0 -0
  93. {plain-0.64.0 → plain-0.65.0}/plain/packages/__init__.py +0 -0
  94. {plain-0.64.0 → plain-0.65.0}/plain/packages/config.py +0 -0
  95. {plain-0.64.0 → plain-0.65.0}/plain/packages/registry.py +0 -0
  96. {plain-0.64.0 → plain-0.65.0}/plain/paginator.py +0 -0
  97. {plain-0.64.0 → plain-0.65.0}/plain/preflight/README.md +0 -0
  98. {plain-0.64.0 → plain-0.65.0}/plain/preflight/__init__.py +0 -0
  99. {plain-0.64.0 → plain-0.65.0}/plain/preflight/files.py +0 -0
  100. {plain-0.64.0 → plain-0.65.0}/plain/preflight/messages.py +0 -0
  101. {plain-0.64.0 → plain-0.65.0}/plain/preflight/registry.py +0 -0
  102. {plain-0.64.0 → plain-0.65.0}/plain/preflight/security.py +0 -0
  103. {plain-0.64.0 → plain-0.65.0}/plain/preflight/urls.py +0 -0
  104. {plain-0.64.0 → plain-0.65.0}/plain/runtime/README.md +0 -0
  105. {plain-0.64.0 → plain-0.65.0}/plain/runtime/__init__.py +0 -0
  106. {plain-0.64.0 → plain-0.65.0}/plain/runtime/user_settings.py +0 -0
  107. {plain-0.64.0 → plain-0.65.0}/plain/runtime/utils.py +0 -0
  108. {plain-0.64.0 → plain-0.65.0}/plain/signals/README.md +0 -0
  109. {plain-0.64.0 → plain-0.65.0}/plain/signals/__init__.py +0 -0
  110. {plain-0.64.0 → plain-0.65.0}/plain/signals/dispatch/__init__.py +0 -0
  111. {plain-0.64.0 → plain-0.65.0}/plain/signals/dispatch/dispatcher.py +0 -0
  112. {plain-0.64.0 → plain-0.65.0}/plain/signals/dispatch/license.txt +0 -0
  113. {plain-0.64.0 → plain-0.65.0}/plain/signing.py +0 -0
  114. {plain-0.64.0 → plain-0.65.0}/plain/templates/AGENTS.md +0 -0
  115. {plain-0.64.0 → plain-0.65.0}/plain/templates/README.md +0 -0
  116. {plain-0.64.0 → plain-0.65.0}/plain/templates/__init__.py +0 -0
  117. {plain-0.64.0 → plain-0.65.0}/plain/templates/core.py +0 -0
  118. {plain-0.64.0 → plain-0.65.0}/plain/templates/jinja/__init__.py +0 -0
  119. {plain-0.64.0 → plain-0.65.0}/plain/templates/jinja/environments.py +0 -0
  120. {plain-0.64.0 → plain-0.65.0}/plain/templates/jinja/extensions.py +0 -0
  121. {plain-0.64.0 → plain-0.65.0}/plain/templates/jinja/filters.py +0 -0
  122. {plain-0.64.0 → plain-0.65.0}/plain/templates/jinja/globals.py +0 -0
  123. {plain-0.64.0 → plain-0.65.0}/plain/test/README.md +0 -0
  124. {plain-0.64.0 → plain-0.65.0}/plain/test/__init__.py +0 -0
  125. {plain-0.64.0 → plain-0.65.0}/plain/test/client.py +0 -0
  126. {plain-0.64.0 → plain-0.65.0}/plain/test/encoding.py +0 -0
  127. {plain-0.64.0 → plain-0.65.0}/plain/test/exceptions.py +0 -0
  128. {plain-0.64.0 → plain-0.65.0}/plain/urls/README.md +0 -0
  129. {plain-0.64.0 → plain-0.65.0}/plain/urls/__init__.py +0 -0
  130. {plain-0.64.0 → plain-0.65.0}/plain/urls/converters.py +0 -0
  131. {plain-0.64.0 → plain-0.65.0}/plain/urls/exceptions.py +0 -0
  132. {plain-0.64.0 → plain-0.65.0}/plain/urls/patterns.py +0 -0
  133. {plain-0.64.0 → plain-0.65.0}/plain/urls/resolvers.py +0 -0
  134. {plain-0.64.0 → plain-0.65.0}/plain/urls/routers.py +0 -0
  135. {plain-0.64.0 → plain-0.65.0}/plain/urls/utils.py +0 -0
  136. {plain-0.64.0 → plain-0.65.0}/plain/utils/README.md +0 -0
  137. {plain-0.64.0 → plain-0.65.0}/plain/utils/__init__.py +0 -0
  138. {plain-0.64.0 → plain-0.65.0}/plain/utils/cache.py +0 -0
  139. {plain-0.64.0 → plain-0.65.0}/plain/utils/crypto.py +0 -0
  140. {plain-0.64.0 → plain-0.65.0}/plain/utils/datastructures.py +0 -0
  141. {plain-0.64.0 → plain-0.65.0}/plain/utils/dateparse.py +0 -0
  142. {plain-0.64.0 → plain-0.65.0}/plain/utils/deconstruct.py +0 -0
  143. {plain-0.64.0 → plain-0.65.0}/plain/utils/decorators.py +0 -0
  144. {plain-0.64.0 → plain-0.65.0}/plain/utils/duration.py +0 -0
  145. {plain-0.64.0 → plain-0.65.0}/plain/utils/encoding.py +0 -0
  146. {plain-0.64.0 → plain-0.65.0}/plain/utils/functional.py +0 -0
  147. {plain-0.64.0 → plain-0.65.0}/plain/utils/hashable.py +0 -0
  148. {plain-0.64.0 → plain-0.65.0}/plain/utils/html.py +0 -0
  149. {plain-0.64.0 → plain-0.65.0}/plain/utils/inspect.py +0 -0
  150. {plain-0.64.0 → plain-0.65.0}/plain/utils/ipv6.py +0 -0
  151. {plain-0.64.0 → plain-0.65.0}/plain/utils/itercompat.py +0 -0
  152. {plain-0.64.0 → plain-0.65.0}/plain/utils/module_loading.py +0 -0
  153. {plain-0.64.0 → plain-0.65.0}/plain/utils/regex_helper.py +0 -0
  154. {plain-0.64.0 → plain-0.65.0}/plain/utils/safestring.py +0 -0
  155. {plain-0.64.0 → plain-0.65.0}/plain/utils/text.py +0 -0
  156. {plain-0.64.0 → plain-0.65.0}/plain/utils/timesince.py +0 -0
  157. {plain-0.64.0 → plain-0.65.0}/plain/utils/timezone.py +0 -0
  158. {plain-0.64.0 → plain-0.65.0}/plain/utils/tree.py +0 -0
  159. {plain-0.64.0 → plain-0.65.0}/plain/validators.py +0 -0
  160. {plain-0.64.0 → plain-0.65.0}/plain/views/README.md +0 -0
  161. {plain-0.64.0 → plain-0.65.0}/plain/views/__init__.py +0 -0
  162. {plain-0.64.0 → plain-0.65.0}/plain/views/base.py +0 -0
  163. {plain-0.64.0 → plain-0.65.0}/plain/views/errors.py +0 -0
  164. {plain-0.64.0 → plain-0.65.0}/plain/views/exceptions.py +0 -0
  165. {plain-0.64.0 → plain-0.65.0}/plain/views/forms.py +0 -0
  166. {plain-0.64.0 → plain-0.65.0}/plain/views/objects.py +0 -0
  167. {plain-0.64.0 → plain-0.65.0}/plain/views/redirect.py +0 -0
  168. {plain-0.64.0 → plain-0.65.0}/plain/views/templates.py +0 -0
  169. {plain-0.64.0 → plain-0.65.0}/plain/wsgi.py +0 -0
  170. {plain-0.64.0 → plain-0.65.0}/tests/.gitignore +0 -0
  171. {plain-0.64.0 → plain-0.65.0}/tests/app/.gitignore +0 -0
  172. {plain-0.64.0 → plain-0.65.0}/tests/app/settings.py +0 -0
  173. {plain-0.64.0 → plain-0.65.0}/tests/app/test/__init__.py +0 -0
  174. {plain-0.64.0 → plain-0.65.0}/tests/app/test/default_settings.py +0 -0
  175. {plain-0.64.0 → plain-0.65.0}/tests/app/urls.py +0 -0
  176. {plain-0.64.0 → plain-0.65.0}/tests/conftest.py +0 -0
  177. {plain-0.64.0 → plain-0.65.0}/tests/test_cli.py +0 -0
  178. {plain-0.64.0 → plain-0.65.0}/tests/test_csrf.py +0 -0
  179. {plain-0.64.0 → plain-0.65.0}/tests/test_logs.py +0 -0
  180. {plain-0.64.0 → plain-0.65.0}/tests/test_runtime.py +0 -0
  181. {plain-0.64.0 → plain-0.65.0}/tests/test_wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.64.0
3
+ Version: 0.65.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,5 +1,15 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.65.0](https://github.com/dropseed/plain/releases/plain@0.65.0) (2025-09-22)
4
+
5
+ ### What's changed
6
+
7
+ - Added CIDR notation support to `ALLOWED_HOSTS` for IP address range validation ([c485d21](https://github.com/dropseed/plain/commit/c485d21a8b))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - No changes required
12
+
3
13
  ## [0.64.0](https://github.com/dropseed/plain/releases/plain@0.64.0) (2025-09-19)
4
14
 
5
15
  ### What's changed
@@ -0,0 +1,147 @@
1
+ """
2
+ Host validation utilities for ALLOWED_HOSTS functionality.
3
+
4
+ This module provides functions for validating hosts against allowed patterns,
5
+ including domain patterns, wildcards, and CIDR notation for IP ranges.
6
+ """
7
+
8
+ import ipaddress
9
+
10
+ from plain.utils.regex_helper import _lazy_re_compile
11
+
12
+ host_validation_re = _lazy_re_compile(
13
+ r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:[0-9]+)?$"
14
+ )
15
+
16
+
17
+ def split_domain_port(host: str) -> tuple[str, str]:
18
+ """
19
+ Return a (domain, port) tuple from a given host.
20
+
21
+ Returned domain is lowercased. If the host is invalid, the domain will be
22
+ empty.
23
+ """
24
+ host = host.lower()
25
+
26
+ if not host_validation_re.match(host):
27
+ return "", ""
28
+
29
+ if host[-1] == "]":
30
+ # It's an IPv6 address without a port.
31
+ return host, ""
32
+ bits = host.rsplit(":", 1)
33
+ domain, port = bits if len(bits) == 2 else (bits[0], "")
34
+ # Remove a trailing dot (if present) from the domain.
35
+ domain = domain.removesuffix(".")
36
+ return domain, port
37
+
38
+
39
+ def _is_same_domain(host: str, pattern: str) -> bool:
40
+ """
41
+ Return ``True`` if the host is either an exact match or a match
42
+ to the wildcard pattern.
43
+
44
+ Any pattern beginning with a period matches a domain and all of its
45
+ subdomains. (e.g. ``.example.com`` matches ``example.com`` and
46
+ ``foo.example.com``). Anything else is an exact string match.
47
+ """
48
+ if not pattern:
49
+ return False
50
+
51
+ pattern = pattern.lower()
52
+ return (
53
+ pattern[0] == "."
54
+ and (host.endswith(pattern) or host == pattern[1:])
55
+ or pattern == host
56
+ )
57
+
58
+
59
+ def _parse_ip_address(
60
+ host: str,
61
+ ) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
62
+ """
63
+ Parse a host string as an IP address (IPv4 or IPv6).
64
+
65
+ Returns the ipaddress.ip_address object if valid, None otherwise.
66
+ Handles both bracketed and non-bracketed IPv6 addresses.
67
+ """
68
+ # Remove brackets from IPv6 addresses
69
+ if host.startswith("[") and host.endswith("]"):
70
+ host = host[1:-1]
71
+
72
+ try:
73
+ return ipaddress.ip_address(host)
74
+ except ValueError:
75
+ return None
76
+
77
+
78
+ def _parse_cidr_pattern(
79
+ pattern: str,
80
+ ) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None:
81
+ """
82
+ Parse a CIDR pattern and return the network object if valid.
83
+
84
+ Returns the ipaddress.ip_network object if valid CIDR notation, None otherwise.
85
+ """
86
+ # Check if it contains a slash (required for CIDR)
87
+ if "/" not in pattern:
88
+ return None
89
+
90
+ # Remove brackets from IPv6 CIDR patterns
91
+ test_pattern = pattern
92
+ if pattern.startswith("[") and "]/" in pattern:
93
+ # Handle format like [2001:db8::]/32
94
+ bracket_end = pattern.find("]/")
95
+ if bracket_end != -1:
96
+ ip_part = pattern[1:bracket_end]
97
+ cidr_part = pattern[bracket_end + 2 :]
98
+ test_pattern = f"{ip_part}/{cidr_part}"
99
+ elif pattern.startswith("[") and pattern.endswith("]") and "/" in pattern:
100
+ # Handle format like [2001:db8::/32] (slash inside brackets)
101
+ test_pattern = pattern[1:-1]
102
+
103
+ try:
104
+ return ipaddress.ip_network(test_pattern, strict=False)
105
+ except ValueError:
106
+ return None
107
+
108
+
109
+ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
110
+ """
111
+ Validate the given host for this site.
112
+
113
+ Check that the host looks valid and matches a host or host pattern in the
114
+ given list of ``allowed_hosts``. Supported patterns:
115
+
116
+ - ``*`` matches anything
117
+ - ``.example.com`` matches a domain and all its subdomains
118
+ (e.g. ``example.com`` and ``sub.example.com``)
119
+ - ``example.com`` matches exactly that domain
120
+ - ``192.168.1.0/24`` matches IP addresses in that CIDR range
121
+ - ``[2001:db8::]/32`` matches IPv6 addresses in that CIDR range
122
+ - ``192.168.1.1`` matches that exact IP address
123
+
124
+ Note: This function assumes that the given host is lowercased and has
125
+ already had the port, if any, stripped off.
126
+
127
+ Return ``True`` for a valid host, ``False`` otherwise.
128
+ """
129
+ # Parse the host as an IP address if possible
130
+ host_ip = _parse_ip_address(host)
131
+
132
+ for pattern in allowed_hosts:
133
+ # Wildcard matches everything
134
+ if pattern == "*":
135
+ return True
136
+
137
+ # Check CIDR notation patterns using walrus operator
138
+ if network := _parse_cidr_pattern(pattern):
139
+ if host_ip and host_ip in network:
140
+ return True
141
+ continue
142
+
143
+ # For non-CIDR patterns, use existing domain matching logic
144
+ if _is_same_domain(host, pattern):
145
+ return True
146
+
147
+ return False
@@ -27,12 +27,9 @@ from plain.utils.datastructures import (
27
27
  MultiValueDict,
28
28
  )
29
29
  from plain.utils.encoding import iri_to_uri
30
- from plain.utils.http import is_same_domain, parse_header_parameters
31
- from plain.utils.regex_helper import _lazy_re_compile
30
+ from plain.utils.http import parse_header_parameters
32
31
 
33
- host_validation_re = _lazy_re_compile(
34
- r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:[0-9]+)?$"
35
- )
32
+ from .hosts import split_domain_port, validate_host
36
33
 
37
34
 
38
35
  class UnreadablePostError(OSError):
@@ -702,47 +699,5 @@ def bytes_to_text(s, encoding):
702
699
  return s
703
700
 
704
701
 
705
- def split_domain_port(host):
706
- """
707
- Return a (domain, port) tuple from a given host.
708
-
709
- Returned domain is lowercased. If the host is invalid, the domain will be
710
- empty.
711
- """
712
- host = host.lower()
713
-
714
- if not host_validation_re.match(host):
715
- return "", ""
716
-
717
- if host[-1] == "]":
718
- # It's an IPv6 address without a port.
719
- return host, ""
720
- bits = host.rsplit(":", 1)
721
- domain, port = bits if len(bits) == 2 else (bits[0], "")
722
- # Remove a trailing dot (if present) from the domain.
723
- domain = domain.removesuffix(".")
724
- return domain, port
725
-
726
-
727
- def validate_host(host, allowed_hosts):
728
- """
729
- Validate the given host for this site.
730
-
731
- Check that the host looks valid and matches a host or host pattern in the
732
- given list of ``allowed_hosts``. Any pattern beginning with a period
733
- matches a domain and all its subdomains (e.g. ``.example.com`` matches
734
- ``example.com`` and any subdomain), ``*`` matches anything, and anything
735
- else must match exactly.
736
-
737
- Note: This function assumes that the given host is lowercased and has
738
- already had the port, if any, stripped off.
739
-
740
- Return ``True`` for a valid host, ``False`` otherwise.
741
- """
742
- return any(
743
- pattern == "*" or is_same_domain(host, pattern) for pattern in allowed_hosts
744
- )
745
-
746
-
747
702
  def parse_accept_header(header):
748
703
  return [MediaType(token) for token in header.split(",") if token.strip()]
@@ -24,6 +24,7 @@ URLS_ROUTER: str
24
24
 
25
25
  # Hosts/domain names that are valid for this site.
26
26
  # "*" matches anything, ".example.com" matches example.com and all subdomains
27
+ # "192.168.1.0/24" matches IP addresses in that CIDR range
27
28
  ALLOWED_HOSTS: list[str] = []
28
29
 
29
30
  # Default headers for all responses.
@@ -92,26 +92,6 @@ def int_to_base36(i):
92
92
  return b36
93
93
 
94
94
 
95
- def is_same_domain(host, pattern):
96
- """
97
- Return ``True`` if the host is either an exact match or a match
98
- to the wildcard pattern.
99
-
100
- Any pattern beginning with a period matches a domain and all of its
101
- subdomains. (e.g. ``.example.com`` matches ``example.com`` and
102
- ``foo.example.com``). Anything else is an exact string match.
103
- """
104
- if not pattern:
105
- return False
106
-
107
- pattern = pattern.lower()
108
- return (
109
- pattern[0] == "."
110
- and (host.endswith(pattern) or host == pattern[1:])
111
- or pattern == host
112
- )
113
-
114
-
115
95
  def escape_leading_slashes(url):
116
96
  """
117
97
  If redirecting to an absolute path (two leading slashes), a slash must be
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain"
3
- version = "0.64.0"
3
+ version = "0.65.0"
4
4
  description = "A web framework for building products with Python."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  readme = "README.md"
@@ -0,0 +1,214 @@
1
+ import pytest
2
+
3
+ from plain.http.hosts import split_domain_port, validate_host
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ ("host", "expected_domain", "expected_port"),
8
+ [
9
+ # IPv4 addresses
10
+ ("127.0.0.1", "127.0.0.1", ""),
11
+ ("192.168.1.1", "192.168.1.1", ""),
12
+ ("127.0.0.1:8000", "127.0.0.1", "8000"),
13
+ ("192.168.1.1:443", "192.168.1.1", "443"),
14
+ # IPv6 addresses
15
+ ("[::1]", "[::1]", ""),
16
+ ("[2001:db8::1]", "[2001:db8::1]", ""),
17
+ (
18
+ "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]",
19
+ "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]",
20
+ "",
21
+ ),
22
+ ("[::1]:8000", "[::1]", "8000"),
23
+ ("[2001:db8::1]:443", "[2001:db8::1]", "443"),
24
+ # Domain names
25
+ ("example.com", "example.com", ""),
26
+ ("sub.example.com", "sub.example.com", ""),
27
+ ("deep.sub.example.com", "deep.sub.example.com", ""),
28
+ ("example.com:8080", "example.com", "8080"),
29
+ ("sub.example.com:443", "sub.example.com", "443"),
30
+ ("api.example.com:3000", "api.example.com", "3000"),
31
+ # Trailing dot removal
32
+ ("example.com.", "example.com", ""),
33
+ ("sub.example.com.:8080", "sub.example.com", "8080"),
34
+ # Case normalization
35
+ ("EXAMPLE.COM", "example.com", ""),
36
+ ("Example.Com:8080", "example.com", "8080"),
37
+ ("SUB.EXAMPLE.COM:443", "sub.example.com", "443"),
38
+ # Borderline valid cases
39
+ ("invalid..domain", "invalid..domain", ""),
40
+ # Invalid hosts
41
+ ("", "", ""),
42
+ ("::1", "", ""), # IPv6 without brackets
43
+ ("not a valid host", "", ""),
44
+ ("example$.com", "", ""),
45
+ ("example.com:", "", ""), # Trailing colon
46
+ ("[::1", "", ""), # Incomplete brackets
47
+ ("example.com:8080:9000", "", ""), # Multiple colons
48
+ ],
49
+ )
50
+ def test_split_domain_port(host, expected_domain, expected_port):
51
+ """Test split_domain_port function with various inputs."""
52
+ domain, port = split_domain_port(host)
53
+ assert domain == expected_domain
54
+ assert port == expected_port
55
+
56
+
57
+ @pytest.mark.parametrize(
58
+ ("host", "allowed_hosts", "expected"),
59
+ [
60
+ # Wildcard matching
61
+ ("example.com", ["*"], True),
62
+ ("sub.example.com", ["*"], True),
63
+ ("127.0.0.1", ["*"], True),
64
+ ("[::1]", ["*"], True),
65
+ ("anything.at.all", ["*"], True),
66
+ # Subdomain pattern matching
67
+ ("example.com", [".example.com"], True),
68
+ ("sub.example.com", [".example.com"], True),
69
+ ("api.example.com", [".example.com"], True),
70
+ ("deep.sub.example.com", [".example.com"], True),
71
+ ("notexample.com", [".example.com"], False),
72
+ ("example.org", [".example.com"], False),
73
+ ("fakeexample.com", [".example.com"], False),
74
+ # Exact domain matching
75
+ ("example.com", ["example.com"], True),
76
+ ("sub.example.com", ["example.com"], False),
77
+ ("api.example.com", ["example.com"], False),
78
+ ("example.org", ["example.com"], False),
79
+ ("notexample.com", ["example.com"], False),
80
+ # Multiple patterns
81
+ ("example.com", ["example.com", ".api.example.com", "127.0.0.1"], True),
82
+ ("api.example.com", ["example.com", ".api.example.com", "127.0.0.1"], True),
83
+ ("v1.api.example.com", ["example.com", ".api.example.com", "127.0.0.1"], True),
84
+ ("127.0.0.1", ["example.com", ".api.example.com", "127.0.0.1"], True),
85
+ ("sub.example.com", ["example.com", ".api.example.com", "127.0.0.1"], False),
86
+ ("other.com", ["example.com", ".api.example.com", "127.0.0.1"], False),
87
+ # Literal asterisk pattern (not treated as wildcard)
88
+ ("*.test.com", ["*.test.com"], True),
89
+ ("anything.test.com", ["*.test.com"], False),
90
+ ("api.test.com", ["*.test.com"], False),
91
+ # IPv4 address matching
92
+ ("127.0.0.1", ["127.0.0.1", "192.168.1.1"], True),
93
+ ("192.168.1.1", ["127.0.0.1", "192.168.1.1"], True),
94
+ ("127.0.0.2", ["127.0.0.1", "192.168.1.1"], False),
95
+ ("10.0.0.1", ["127.0.0.1", "192.168.1.1"], False),
96
+ # IPv6 address matching
97
+ ("[::1]", ["[::1]", "[2001:db8::1]"], True),
98
+ ("[2001:db8::1]", ["[::1]", "[2001:db8::1]"], True),
99
+ ("[::2]", ["[::1]", "[2001:db8::1]"], False),
100
+ ("[2001:db8::2]", ["[::1]", "[2001:db8::1]"], False),
101
+ # Pattern case insensitive matching (host should be lowercase already)
102
+ ("example.com", [".Example.Com"], True),
103
+ ("sub.example.com", [".Example.Com"], True),
104
+ ("api.test.com", ["API.TEST.COM"], True),
105
+ # Empty allowed_hosts
106
+ ("example.com", [], False),
107
+ ("127.0.0.1", [], False),
108
+ ("[::1]", [], False),
109
+ # Empty pattern in allowed_hosts
110
+ ("", ["", "example.com"], False),
111
+ ("anything.com", ["", "example.com"], False),
112
+ ("example.com", ["", "example.com"], True),
113
+ # Complex subdomain patterns
114
+ ("api.example.com", [".api.example.com", ".staging.example.com"], True),
115
+ ("staging.example.com", [".api.example.com", ".staging.example.com"], True),
116
+ ("v1.api.example.com", [".api.example.com", ".staging.example.com"], True),
117
+ (
118
+ "beta.staging.example.com",
119
+ [".api.example.com", ".staging.example.com"],
120
+ True,
121
+ ),
122
+ ("example.com", [".api.example.com", ".staging.example.com"], False),
123
+ ("www.example.com", [".api.example.com", ".staging.example.com"], False),
124
+ # Wildcard overrides other patterns
125
+ (
126
+ "anything.com",
127
+ ["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
128
+ True,
129
+ ),
130
+ (
131
+ "random.domain.org",
132
+ ["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
133
+ True,
134
+ ),
135
+ (
136
+ "192.168.1.100",
137
+ ["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
138
+ True,
139
+ ),
140
+ # Edge cases
141
+ ("", ["example.com"], False),
142
+ ("example .com", ["example.com"], False),
143
+ ("a" * 50 + ".example.com", [".example.com"], True), # Very long subdomain
144
+ ],
145
+ )
146
+ def test_validate_host(host, allowed_hosts, expected):
147
+ """Test validate_host function with various inputs."""
148
+ assert validate_host(host, allowed_hosts) is expected
149
+
150
+
151
+ @pytest.mark.parametrize(
152
+ ("host", "allowed_hosts", "expected"),
153
+ [
154
+ # IPv4 CIDR tests
155
+ ("192.168.1.100", ["192.168.1.0/24"], True),
156
+ ("192.168.1.1", ["192.168.1.0/24"], True),
157
+ ("192.168.1.254", ["192.168.1.0/24"], True),
158
+ ("192.168.2.100", ["192.168.1.0/24"], False),
159
+ ("10.0.5.1", ["10.0.0.0/8"], True),
160
+ ("172.16.0.1", ["10.0.0.0/8"], False),
161
+ # IPv4 single IP as CIDR
162
+ ("192.168.1.1", ["192.168.1.1/32"], True),
163
+ ("192.168.1.2", ["192.168.1.1/32"], False),
164
+ # IPv4 larger networks
165
+ ("172.16.5.10", ["172.16.0.0/12"], True),
166
+ ("172.32.5.10", ["172.16.0.0/12"], False),
167
+ ("127.0.0.1", ["127.0.0.0/8"], True),
168
+ # IPv6 CIDR tests
169
+ ("[2001:db8::1]", ["[2001:db8::]/32"], True),
170
+ ("[2001:db8:1::1]", ["[2001:db8::]/32"], True),
171
+ ("[2001:db9::1]", ["[2001:db8::]/32"], False),
172
+ ("[::1]", ["[::]/0"], True), # Match everything IPv6
173
+ ("[2001:db8::1]", ["[fe80::]/10"], False),
174
+ # IPv6 without brackets in pattern (should still work)
175
+ ("[2001:db8::1]", ["2001:db8::/32"], True),
176
+ ("[2001:db9::1]", ["2001:db8::/32"], False),
177
+ # IPv6 single address as CIDR
178
+ ("[::1]", ["[::1]/128"], True),
179
+ ("[::2]", ["[::1]/128"], False),
180
+ # Mixed CIDR and domain patterns
181
+ ("192.168.1.50", ["192.168.1.0/24", ".example.com"], True),
182
+ ("sub.example.com", ["192.168.1.0/24", ".example.com"], True),
183
+ ("192.168.2.50", ["192.168.1.0/24", ".example.com"], False),
184
+ ("other.com", ["192.168.1.0/24", ".example.com"], False),
185
+ # Multiple CIDR patterns
186
+ ("192.168.1.50", ["10.0.0.0/8", "192.168.0.0/16"], True),
187
+ ("10.5.0.1", ["10.0.0.0/8", "192.168.0.0/16"], True),
188
+ ("172.16.0.1", ["10.0.0.0/8", "192.168.0.0/16"], False),
189
+ # Domain names should not match CIDR patterns
190
+ ("example.com", ["192.168.1.0/24"], False),
191
+ ("192.168.1.com", ["192.168.1.0/24"], False),
192
+ # Non-IP strings should not match CIDR
193
+ ("not-an-ip", ["192.168.1.0/24"], False),
194
+ ("192.168.1", ["192.168.1.0/24"], False), # Incomplete IP
195
+ # Invalid CIDR patterns should be ignored (treated as literal)
196
+ ("192.168.1.0/24", ["192.168.1.0/24"], False), # Literal match of CIDR string
197
+ ("192.168.1.100", ["192.168.1.0/999"], False), # Invalid CIDR
198
+ ("192.168.1.100", ["192.168.1.0/"], False), # Invalid CIDR
199
+ # CIDR with wildcard - wildcard should take precedence
200
+ ("anything.com", ["*", "192.168.1.0/24"], True),
201
+ ("172.16.0.1", ["*", "192.168.1.0/24"], True),
202
+ # Edge cases
203
+ ("0.0.0.0", ["0.0.0.0/0"], True), # Match all IPv4
204
+ ("255.255.255.255", ["0.0.0.0/0"], True),
205
+ (
206
+ "[ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff]",
207
+ ["[::]/0"],
208
+ True,
209
+ ), # Match all IPv6
210
+ ],
211
+ )
212
+ def test_validate_host_cidr(host, allowed_hosts, expected):
213
+ """Test validate_host function with CIDR notation patterns."""
214
+ assert validate_host(host, allowed_hosts) is expected
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes