Skip to content

Commit

Permalink
Merge branch '3.0.x'
Browse files Browse the repository at this point in the history
  • Loading branch information
davidism committed May 5, 2024
2 parents c58d391 + 1565490 commit 5d33a39
Show file tree
Hide file tree
Showing 13 changed files with 114 additions and 49 deletions.
15 changes: 14 additions & 1 deletion CHANGES.rst
Expand Up @@ -10,7 +10,20 @@ Unreleased
Version 3.0.3
-------------

Unreleased
Released 2024-05-05

- Only allow ``localhost``, ``.localhost``, ``127.0.0.1``, or the specified
hostname when running the dev server, to make debugger requests. Additional
hosts can be added by using the debugger middleware directly. The debugger
UI makes requests using the full URL rather than only the path.
:ghsa:`2g68-c3qc-8985`
- Make reloader more robust when ``""`` is in ``sys.path``. :pr:`2823`
- Better TLS cert format with ``adhoc`` dev certs. :pr:`2891`
- Inform Python < 3.12 how to handle ``itms-services`` URIs correctly, rather
than using an overly-broad workaround in Werkzeug that caused some redirect
URIs to be passed on without encoding. :issue:`2828`
- Type annotation for ``Rule.endpoint`` and other uses of ``endpoint`` is
``Any``. :issue:`2836`

- Make reloader more robust when ``""`` is in ``sys.path``. :pr:`2823`

Expand Down
35 changes: 30 additions & 5 deletions docs/debug.rst
Expand Up @@ -16,7 +16,8 @@ interactive debug console to execute code in any frame.
The debugger allows the execution of arbitrary code which makes it a
major security risk. **The debugger must never be used on production
machines. We cannot stress this enough. Do not enable the debugger
in production.**
in production.** Production means anything that is not development,
and anything that is publicly accessible.

.. note::

Expand Down Expand Up @@ -72,10 +73,9 @@ argument to get a detailed list of all the attributes it has.
Debugger PIN
------------

Starting with Werkzeug 0.11 the debug console is protected by a PIN.
This is a security helper to make it less likely for the debugger to be
exploited if you forget to disable it when deploying to production. The
PIN based authentication is enabled by default.
The debug console is protected by a PIN. This is a security helper to make it
less likely for the debugger to be exploited if you forget to disable it when
deploying to production. The PIN based authentication is enabled by default.

The first time a console is opened, a dialog will prompt for a PIN that
is printed to the command line. The PIN is generated in a stable way
Expand All @@ -92,6 +92,31 @@ intended to make it harder for an attacker to exploit the debugger.
Never enable the debugger in production.**


Allowed Hosts
-------------

The debug console will only be served if the request comes from a trusted host.
If a request comes from a browser page that is not served on a trusted URL, a
400 error will be returned.

By default, ``localhost``, any ``.localhost`` subdomain, and ``127.0.0.1`` are
trusted. ``run_simple`` will trust its ``hostname`` argument as well. To change
this further, use the debug middleware directly rather than through
``use_debugger=True``.

.. code-block:: python
if os.environ.get("USE_DEBUGGER") in {"1", "true"}:
app = DebuggedApplication(app, evalex=True)
app.trusted_hosts = [...]
run_simple("localhost", 8080, app)
**This feature is not meant to entirely secure the debugger. It is
intended to make it harder for an attacker to exploit the debugger.
Never enable the debugger in production.**


Pasting Errors
--------------

Expand Down
31 changes: 28 additions & 3 deletions src/werkzeug/debug/__init__.py
Expand Up @@ -19,7 +19,9 @@

from .._internal import _log
from ..exceptions import NotFound
from ..exceptions import SecurityError
from ..http import parse_cookie
from ..sansio.utils import host_is_trusted
from ..security import gen_salt
from ..utils import send_file
from ..wrappers.request import Request
Expand Down Expand Up @@ -298,6 +300,14 @@ def __init__(
else:
self.pin = None

self.trusted_hosts: list[str] = [".localhost", "127.0.0.1"]
"""List of domains to allow requests to the debugger from. A leading dot
allows all subdomains. This only allows ``".localhost"`` domains by
default.
.. versionadded:: 3.0.3
"""

@property
def pin(self) -> str | None:
if not hasattr(self, "_pin"):
Expand Down Expand Up @@ -344,7 +354,7 @@ def debug_application(

is_trusted = bool(self.check_pin_trust(environ))
html = tb.render_debugger_html(
evalex=self.evalex,
evalex=self.evalex and self.check_host_trust(environ),
secret=self.secret,
evalex_trusted=is_trusted,
)
Expand Down Expand Up @@ -372,6 +382,9 @@ def execute_command( # type: ignore[return]
frame: DebugFrameSummary | _ConsoleFrame,
) -> Response:
"""Execute a command in a console."""
if not self.check_host_trust(request.environ):
return SecurityError() # type: ignore[return-value]

contexts = self.frame_contexts.get(id(frame), [])

with ExitStack() as exit_stack:
Expand All @@ -382,6 +395,9 @@ def execute_command( # type: ignore[return]

def display_console(self, request: Request) -> Response:
"""Display a standalone shell."""
if not self.check_host_trust(request.environ):
return SecurityError() # type: ignore[return-value]

if 0 not in self.frames:
if self.console_init_func is None:
ns = {}
Expand Down Expand Up @@ -434,12 +450,18 @@ def check_pin_trust(self, environ: WSGIEnvironment) -> bool | None:
return None
return (time.time() - PIN_TIME) < ts

def check_host_trust(self, environ: WSGIEnvironment) -> bool:
return host_is_trusted(environ.get("HTTP_HOST"), self.trusted_hosts)

def _fail_pin_auth(self) -> None:
time.sleep(5.0 if self._failed_pin_auth > 5 else 0.5)
self._failed_pin_auth += 1

def pin_auth(self, request: Request) -> Response:
"""Authenticates with the pin."""
if not self.check_host_trust(request.environ):
return SecurityError() # type: ignore[return-value]

exhausted = False
auth = False
trust = self.check_pin_trust(request.environ)
Expand Down Expand Up @@ -489,8 +511,11 @@ def pin_auth(self, request: Request) -> Response:
rv.delete_cookie(self.pin_cookie_name)
return rv

def log_pin_request(self) -> Response:
def log_pin_request(self, request: Request) -> Response:
"""Log the pin if needed."""
if not self.check_host_trust(request.environ):
return SecurityError() # type: ignore[return-value]

if self.pin_logging and self.pin is not None:
_log(
"info", " * To enable the debugger you need to enter the security pin:"
Expand All @@ -517,7 +542,7 @@ def __call__(
elif cmd == "pinauth" and secret == self.secret:
response = self.pin_auth(request) # type: ignore
elif cmd == "printpin" and secret == self.secret:
response = self.log_pin_request() # type: ignore
response = self.log_pin_request(request) # type: ignore
elif (
self.evalex
and cmd is not None
Expand Down
4 changes: 2 additions & 2 deletions src/werkzeug/debug/shared/debugger.js
Expand Up @@ -48,7 +48,7 @@ function initPinBox() {
btn.disabled = true;

fetch(
`${document.location.pathname}?__debugger__=yes&cmd=pinauth&pin=${pin}&s=${encodedSecret}`
`${document.location}?__debugger__=yes&cmd=pinauth&pin=${pin}&s=${encodedSecret}`
)
.then((res) => res.json())
.then(({auth, exhausted}) => {
Expand Down Expand Up @@ -79,7 +79,7 @@ function promptForPin() {
if (!EVALEX_TRUSTED) {
const encodedSecret = encodeURIComponent(SECRET);
fetch(
`${document.location.pathname}?__debugger__=yes&cmd=printpin&s=${encodedSecret}`
`${document.location}?__debugger__=yes&cmd=printpin&s=${encodedSecret}`
);
const pinPrompt = document.getElementsByClassName("pin-prompt")[0];
fadeIn(pinPrompt);
Expand Down
9 changes: 6 additions & 3 deletions src/werkzeug/routing/exceptions.py
Expand Up @@ -59,7 +59,7 @@ def __init__(self, path_info: str) -> None:
class RequestAliasRedirect(RoutingException): # noqa: B903
"""This rule is an alias and wants to redirect to the canonical URL."""

def __init__(self, matched_values: t.Mapping[str, t.Any], endpoint: str) -> None:
def __init__(self, matched_values: t.Mapping[str, t.Any], endpoint: t.Any) -> None:
super().__init__()
self.matched_values = matched_values
self.endpoint = endpoint
Expand All @@ -72,7 +72,7 @@ class BuildError(RoutingException, LookupError):

def __init__(
self,
endpoint: str,
endpoint: t.Any,
values: t.Mapping[str, t.Any],
method: str | None,
adapter: MapAdapter | None = None,
Expand All @@ -93,7 +93,10 @@ def _score_rule(rule: Rule) -> float:
[
0.98
* difflib.SequenceMatcher(
None, rule.endpoint, self.endpoint
# endpoints can be any type, compare as strings
None,
str(rule.endpoint),
str(self.endpoint),
).ratio(),
0.01 * bool(set(self.values or ()).issubset(rule.arguments)),
0.01 * bool(rule.methods and self.method in rule.methods),
Expand Down
18 changes: 9 additions & 9 deletions src/werkzeug/routing/map.py
Expand Up @@ -104,7 +104,7 @@ def __init__(
host_matching: bool = False,
) -> None:
self._matcher = StateMachineMatcher(merge_slashes)
self._rules_by_endpoint: dict[str, list[Rule]] = {}
self._rules_by_endpoint: dict[t.Any, list[Rule]] = {}
self._remap = True
self._remap_lock = self.lock_class()

Expand All @@ -131,7 +131,7 @@ def merge_slashes(self) -> bool:
def merge_slashes(self, value: bool) -> None:
self._matcher.merge_slashes = value

def is_endpoint_expecting(self, endpoint: str, *arguments: str) -> bool:
def is_endpoint_expecting(self, endpoint: t.Any, *arguments: str) -> bool:
"""Iterate over all rules and check if the endpoint expects
the arguments provided. This is for example useful if you have
some URLs that expect a language code and others that do not and
Expand All @@ -155,7 +155,7 @@ def is_endpoint_expecting(self, endpoint: str, *arguments: str) -> bool:
def _rules(self) -> list[Rule]:
return [rule for rules in self._rules_by_endpoint.values() for rule in rules]

def iter_rules(self, endpoint: str | None = None) -> t.Iterator[Rule]:
def iter_rules(self, endpoint: t.Any | None = None) -> t.Iterator[Rule]:
"""Iterate over all rules or the rules of an endpoint.
:param endpoint: if provided only the rules for that endpoint
Expand Down Expand Up @@ -470,14 +470,14 @@ def application(environ, start_response):
raise

@t.overload
def match( # type: ignore
def match(
self,
path_info: str | None = None,
method: str | None = None,
return_rule: t.Literal[False] = False,
query_args: t.Mapping[str, t.Any] | str | None = None,
websocket: bool | None = None,
) -> tuple[str, t.Mapping[str, t.Any]]: ...
) -> tuple[t.Any, t.Mapping[str, t.Any]]: ...

@t.overload
def match(
Expand All @@ -496,7 +496,7 @@ def match(
return_rule: bool = False,
query_args: t.Mapping[str, t.Any] | str | None = None,
websocket: bool | None = None,
) -> tuple[str | Rule, t.Mapping[str, t.Any]]:
) -> tuple[t.Any | Rule, t.Mapping[str, t.Any]]:
"""The usage is simple: you just pass the match method the current
path info as well as the method (which defaults to `GET`). The
following things can then happen:
Expand Down Expand Up @@ -770,7 +770,7 @@ def make_redirect_url(
def make_alias_redirect_url(
self,
path: str,
endpoint: str,
endpoint: t.Any,
values: t.Mapping[str, t.Any],
method: str,
query_args: t.Mapping[str, t.Any] | str,
Expand All @@ -786,7 +786,7 @@ def make_alias_redirect_url(

def _partial_build(
self,
endpoint: str,
endpoint: t.Any,
values: t.Mapping[str, t.Any],
method: str | None,
append_unknown: bool,
Expand Down Expand Up @@ -827,7 +827,7 @@ def _partial_build(

def build(
self,
endpoint: str,
endpoint: t.Any,
values: t.Mapping[str, t.Any] | None = None,
method: str | None = None,
force_external: bool = False,
Expand Down
4 changes: 2 additions & 2 deletions src/werkzeug/routing/rules.py
Expand Up @@ -453,7 +453,7 @@ def __init__(
subdomain: str | None = None,
methods: t.Iterable[str] | None = None,
build_only: bool = False,
endpoint: str | None = None,
endpoint: t.Any | None = None,
strict_slashes: bool | None = None,
merge_slashes: bool | None = None,
redirect_to: str | t.Callable[..., str] | None = None,
Expand Down Expand Up @@ -493,7 +493,7 @@ def __init__(
)

self.methods = methods
self.endpoint: str = endpoint # type: ignore
self.endpoint: t.Any = endpoint
self.redirect_to = redirect_to

if defaults:
Expand Down
2 changes: 1 addition & 1 deletion src/werkzeug/sansio/utils.py
Expand Up @@ -8,7 +8,7 @@
from ..urls import uri_to_iri


def host_is_trusted(hostname: str, trusted_list: t.Iterable[str]) -> bool:
def host_is_trusted(hostname: str | None, trusted_list: t.Iterable[str]) -> bool:
"""Check if a host matches a list of trusted names.
:param hostname: The name to check.
Expand Down
10 changes: 8 additions & 2 deletions src/werkzeug/serving.py
Expand Up @@ -532,7 +532,10 @@ def generate_adhoc_ssl_pair(
.not_valid_before(dt.now(timezone.utc))
.not_valid_after(dt.now(timezone.utc) + timedelta(days=365))
.add_extension(x509.ExtendedKeyUsage([x509.OID_SERVER_AUTH]), critical=False)
.add_extension(x509.SubjectAlternativeName([x509.DNSName(cn)]), critical=False)
.add_extension(
x509.SubjectAlternativeName([x509.DNSName(cn), x509.DNSName(f"*.{cn}")]),
critical=False,
)
.sign(pkey, hashes.SHA256(), backend)
)
return cert, pkey
Expand Down Expand Up @@ -560,7 +563,7 @@ def make_ssl_devcert(
"""

if host is not None:
cn = f"*.{host}/CN={host}"
cn = host
cert, pkey = generate_adhoc_ssl_pair(cn=cn)

from cryptography.hazmat.primitives import serialization
Expand Down Expand Up @@ -1069,6 +1072,9 @@ def run_simple(
from .debug import DebuggedApplication

application = DebuggedApplication(application, evalex=use_evalex)
# Allow the specified hostname to use the debugger, in addition to
# localhost domains.
application.trusted_hosts.append(hostname)

if not is_running_from_reloader():
fd = None
Expand Down
25 changes: 6 additions & 19 deletions src/werkzeug/urls.py
Expand Up @@ -3,6 +3,7 @@
import codecs
import re
import typing as t
import urllib.parse
from urllib.parse import quote
from urllib.parse import unquote
from urllib.parse import urlencode
Expand Down Expand Up @@ -164,25 +165,11 @@ def iri_to_uri(iri: str) -> str:
return urlunsplit((parts.scheme, netloc, path, query, fragment))


def _invalid_iri_to_uri(iri: str) -> str:
"""The URL scheme ``itms-services://`` must contain the ``//`` even though it does
not have a host component. There may be other invalid schemes as well. Currently,
responses will always call ``iri_to_uri`` on the redirect ``Location`` header, which
removes the ``//``. For now, if the IRI only contains ASCII and does not contain
spaces, pass it on as-is. In Werkzeug 3.0, this should become a
``response.process_location`` flag.
:meta private:
"""
try:
iri.encode("ascii")
except UnicodeError:
pass
else:
if len(iri.split(None, 1)) == 1:
return iri

return iri_to_uri(iri)
# Python < 3.12
# itms-services was worked around in previous iri_to_uri implementations, but
# we can tell Python directly that it needs to preserve the //.
if "itms-services" not in urllib.parse.uses_netloc:
urllib.parse.uses_netloc.append("itms-services")


def _decode_idna(domain: str) -> str:
Expand Down

0 comments on commit 5d33a39

Please sign in to comment.