You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
501 lines
18 KiB
501 lines
18 KiB
import getpass
|
|
import hashlib
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import pkgutil
|
|
import re
|
|
import sys
|
|
import time
|
|
import typing as t
|
|
import uuid
|
|
from itertools import chain
|
|
from os.path import basename
|
|
from os.path import join
|
|
|
|
from .._internal import _log
|
|
from ..http import parse_cookie
|
|
from ..security import gen_salt
|
|
from ..wrappers.request import Request
|
|
from ..wrappers.response import Response
|
|
from .console import Console
|
|
from .tbtools import Frame
|
|
from .tbtools import get_current_traceback
|
|
from .tbtools import render_console_html
|
|
from .tbtools import Traceback
|
|
|
|
if t.TYPE_CHECKING:
|
|
from _typeshed.wsgi import StartResponse
|
|
from _typeshed.wsgi import WSGIApplication
|
|
from _typeshed.wsgi import WSGIEnvironment
|
|
|
|
# A week
|
|
PIN_TIME = 60 * 60 * 24 * 7
|
|
|
|
|
|
def hash_pin(pin: str) -> str:
|
|
return hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]
|
|
|
|
|
|
_machine_id: t.Optional[t.Union[str, bytes]] = None
|
|
|
|
|
|
def get_machine_id() -> t.Optional[t.Union[str, bytes]]:
|
|
global _machine_id
|
|
|
|
if _machine_id is not None:
|
|
return _machine_id
|
|
|
|
def _generate() -> t.Optional[t.Union[str, bytes]]:
|
|
linux = b""
|
|
|
|
# machine-id is stable across boots, boot_id is not.
|
|
for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
|
|
try:
|
|
with open(filename, "rb") as f:
|
|
value = f.readline().strip()
|
|
except OSError:
|
|
continue
|
|
|
|
if value:
|
|
linux += value
|
|
break
|
|
|
|
# Containers share the same machine id, add some cgroup
|
|
# information. This is used outside containers too but should be
|
|
# relatively stable across boots.
|
|
try:
|
|
with open("/proc/self/cgroup", "rb") as f:
|
|
linux += f.readline().strip().rpartition(b"/")[2]
|
|
except OSError:
|
|
pass
|
|
|
|
if linux:
|
|
return linux
|
|
|
|
# On OS X, use ioreg to get the computer's serial number.
|
|
try:
|
|
# subprocess may not be available, e.g. Google App Engine
|
|
# https://github.com/pallets/werkzeug/issues/925
|
|
from subprocess import Popen, PIPE
|
|
|
|
dump = Popen(
|
|
["ioreg", "-c", "IOPlatformExpertDevice", "-d", "2"], stdout=PIPE
|
|
).communicate()[0]
|
|
match = re.search(b'"serial-number" = <([^>]+)', dump)
|
|
|
|
if match is not None:
|
|
return match.group(1)
|
|
except (OSError, ImportError):
|
|
pass
|
|
|
|
# On Windows, use winreg to get the machine guid.
|
|
try:
|
|
import winreg
|
|
except ImportError:
|
|
pass
|
|
else:
|
|
try:
|
|
with winreg.OpenKey(
|
|
winreg.HKEY_LOCAL_MACHINE,
|
|
"SOFTWARE\\Microsoft\\Cryptography",
|
|
0,
|
|
winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
|
|
) as rk:
|
|
guid: t.Union[str, bytes]
|
|
guid_type: int
|
|
guid, guid_type = winreg.QueryValueEx(rk, "MachineGuid")
|
|
|
|
if guid_type == winreg.REG_SZ:
|
|
return guid.encode("utf-8") # type: ignore
|
|
|
|
return guid
|
|
except OSError:
|
|
pass
|
|
|
|
return None
|
|
|
|
_machine_id = _generate()
|
|
return _machine_id
|
|
|
|
|
|
class _ConsoleFrame:
|
|
"""Helper class so that we can reuse the frame console code for the
|
|
standalone console.
|
|
"""
|
|
|
|
def __init__(self, namespace: t.Dict[str, t.Any]):
|
|
self.console = Console(namespace)
|
|
self.id = 0
|
|
|
|
|
|
def get_pin_and_cookie_name(
|
|
app: "WSGIApplication",
|
|
) -> t.Union[t.Tuple[str, str], t.Tuple[None, None]]:
|
|
"""Given an application object this returns a semi-stable 9 digit pin
|
|
code and a random key. The hope is that this is stable between
|
|
restarts to not make debugging particularly frustrating. If the pin
|
|
was forcefully disabled this returns `None`.
|
|
|
|
Second item in the resulting tuple is the cookie name for remembering.
|
|
"""
|
|
pin = os.environ.get("WERKZEUG_DEBUG_PIN")
|
|
rv = None
|
|
num = None
|
|
|
|
# Pin was explicitly disabled
|
|
if pin == "off":
|
|
return None, None
|
|
|
|
# Pin was provided explicitly
|
|
if pin is not None and pin.replace("-", "").isdigit():
|
|
# If there are separators in the pin, return it directly
|
|
if "-" in pin:
|
|
rv = pin
|
|
else:
|
|
num = pin
|
|
|
|
modname = getattr(app, "__module__", t.cast(object, app).__class__.__module__)
|
|
username: t.Optional[str]
|
|
|
|
try:
|
|
# getuser imports the pwd module, which does not exist in Google
|
|
# App Engine. It may also raise a KeyError if the UID does not
|
|
# have a username, such as in Docker.
|
|
username = getpass.getuser()
|
|
except (ImportError, KeyError):
|
|
username = None
|
|
|
|
mod = sys.modules.get(modname)
|
|
|
|
# This information only exists to make the cookie unique on the
|
|
# computer, not as a security feature.
|
|
probably_public_bits = [
|
|
username,
|
|
modname,
|
|
getattr(app, "__name__", type(app).__name__),
|
|
getattr(mod, "__file__", None),
|
|
]
|
|
|
|
# This information is here to make it harder for an attacker to
|
|
# guess the cookie name. They are unlikely to be contained anywhere
|
|
# within the unauthenticated debug page.
|
|
private_bits = [str(uuid.getnode()), get_machine_id()]
|
|
|
|
h = hashlib.sha1()
|
|
for bit in chain(probably_public_bits, private_bits):
|
|
if not bit:
|
|
continue
|
|
if isinstance(bit, str):
|
|
bit = bit.encode("utf-8")
|
|
h.update(bit)
|
|
h.update(b"cookiesalt")
|
|
|
|
cookie_name = f"__wzd{h.hexdigest()[:20]}"
|
|
|
|
# If we need to generate a pin we salt it a bit more so that we don't
|
|
# end up with the same value and generate out 9 digits
|
|
if num is None:
|
|
h.update(b"pinsalt")
|
|
num = f"{int(h.hexdigest(), 16):09d}"[:9]
|
|
|
|
# Format the pincode in groups of digits for easier remembering if
|
|
# we don't have a result yet.
|
|
if rv is None:
|
|
for group_size in 5, 4, 3:
|
|
if len(num) % group_size == 0:
|
|
rv = "-".join(
|
|
num[x : x + group_size].rjust(group_size, "0")
|
|
for x in range(0, len(num), group_size)
|
|
)
|
|
break
|
|
else:
|
|
rv = num
|
|
|
|
return rv, cookie_name
|
|
|
|
|
|
class DebuggedApplication:
|
|
"""Enables debugging support for a given application::
|
|
|
|
from werkzeug.debug import DebuggedApplication
|
|
from myapp import app
|
|
app = DebuggedApplication(app, evalex=True)
|
|
|
|
The `evalex` keyword argument allows evaluating expressions in a
|
|
traceback's frame context.
|
|
|
|
:param app: the WSGI application to run debugged.
|
|
:param evalex: enable exception evaluation feature (interactive
|
|
debugging). This requires a non-forking server.
|
|
:param request_key: The key that points to the request object in ths
|
|
environment. This parameter is ignored in current
|
|
versions.
|
|
:param console_path: the URL for a general purpose console.
|
|
:param console_init_func: the function that is executed before starting
|
|
the general purpose console. The return value
|
|
is used as initial namespace.
|
|
:param show_hidden_frames: by default hidden traceback frames are skipped.
|
|
You can show them by setting this parameter
|
|
to `True`.
|
|
:param pin_security: can be used to disable the pin based security system.
|
|
:param pin_logging: enables the logging of the pin system.
|
|
"""
|
|
|
|
_pin: str
|
|
_pin_cookie: str
|
|
|
|
def __init__(
|
|
self,
|
|
app: "WSGIApplication",
|
|
evalex: bool = False,
|
|
request_key: str = "werkzeug.request",
|
|
console_path: str = "/console",
|
|
console_init_func: t.Optional[t.Callable[[], t.Dict[str, t.Any]]] = None,
|
|
show_hidden_frames: bool = False,
|
|
pin_security: bool = True,
|
|
pin_logging: bool = True,
|
|
) -> None:
|
|
if not console_init_func:
|
|
console_init_func = None
|
|
self.app = app
|
|
self.evalex = evalex
|
|
self.frames: t.Dict[int, t.Union[Frame, _ConsoleFrame]] = {}
|
|
self.tracebacks: t.Dict[int, Traceback] = {}
|
|
self.request_key = request_key
|
|
self.console_path = console_path
|
|
self.console_init_func = console_init_func
|
|
self.show_hidden_frames = show_hidden_frames
|
|
self.secret = gen_salt(20)
|
|
self._failed_pin_auth = 0
|
|
|
|
self.pin_logging = pin_logging
|
|
if pin_security:
|
|
# Print out the pin for the debugger on standard out.
|
|
if os.environ.get("WERKZEUG_RUN_MAIN") == "true" and pin_logging:
|
|
_log("warning", " * Debugger is active!")
|
|
if self.pin is None:
|
|
_log("warning", " * Debugger PIN disabled. DEBUGGER UNSECURED!")
|
|
else:
|
|
_log("info", " * Debugger PIN: %s", self.pin)
|
|
else:
|
|
self.pin = None
|
|
|
|
@property
|
|
def pin(self) -> t.Optional[str]:
|
|
if not hasattr(self, "_pin"):
|
|
pin_cookie = get_pin_and_cookie_name(self.app)
|
|
self._pin, self._pin_cookie = pin_cookie # type: ignore
|
|
return self._pin
|
|
|
|
@pin.setter
|
|
def pin(self, value: str) -> None:
|
|
self._pin = value
|
|
|
|
@property
|
|
def pin_cookie_name(self) -> str:
|
|
"""The name of the pin cookie."""
|
|
if not hasattr(self, "_pin_cookie"):
|
|
pin_cookie = get_pin_and_cookie_name(self.app)
|
|
self._pin, self._pin_cookie = pin_cookie # type: ignore
|
|
return self._pin_cookie
|
|
|
|
def debug_application(
|
|
self, environ: "WSGIEnvironment", start_response: "StartResponse"
|
|
) -> t.Iterator[bytes]:
|
|
"""Run the application and conserve the traceback frames."""
|
|
app_iter = None
|
|
try:
|
|
app_iter = self.app(environ, start_response)
|
|
yield from app_iter
|
|
if hasattr(app_iter, "close"):
|
|
app_iter.close() # type: ignore
|
|
except Exception:
|
|
if hasattr(app_iter, "close"):
|
|
app_iter.close() # type: ignore
|
|
traceback = get_current_traceback(
|
|
skip=1,
|
|
show_hidden_frames=self.show_hidden_frames,
|
|
ignore_system_exceptions=True,
|
|
)
|
|
for frame in traceback.frames:
|
|
self.frames[frame.id] = frame
|
|
self.tracebacks[traceback.id] = traceback
|
|
|
|
try:
|
|
start_response(
|
|
"500 INTERNAL SERVER ERROR",
|
|
[
|
|
("Content-Type", "text/html; charset=utf-8"),
|
|
# Disable Chrome's XSS protection, the debug
|
|
# output can cause false-positives.
|
|
("X-XSS-Protection", "0"),
|
|
],
|
|
)
|
|
except Exception:
|
|
# if we end up here there has been output but an error
|
|
# occurred. in that situation we can do nothing fancy any
|
|
# more, better log something into the error log and fall
|
|
# back gracefully.
|
|
environ["wsgi.errors"].write(
|
|
"Debugging middleware caught exception in streamed "
|
|
"response at a point where response headers were already "
|
|
"sent.\n"
|
|
)
|
|
else:
|
|
is_trusted = bool(self.check_pin_trust(environ))
|
|
yield traceback.render_full(
|
|
evalex=self.evalex, evalex_trusted=is_trusted, secret=self.secret
|
|
).encode("utf-8", "replace")
|
|
|
|
traceback.log(environ["wsgi.errors"])
|
|
|
|
def execute_command(
|
|
self, request: Request, command: str, frame: t.Union[Frame, _ConsoleFrame]
|
|
) -> Response:
|
|
"""Execute a command in a console."""
|
|
return Response(frame.console.eval(command), mimetype="text/html")
|
|
|
|
def display_console(self, request: Request) -> Response:
|
|
"""Display a standalone shell."""
|
|
if 0 not in self.frames:
|
|
if self.console_init_func is None:
|
|
ns = {}
|
|
else:
|
|
ns = dict(self.console_init_func())
|
|
ns.setdefault("app", self.app)
|
|
self.frames[0] = _ConsoleFrame(ns)
|
|
is_trusted = bool(self.check_pin_trust(request.environ))
|
|
return Response(
|
|
render_console_html(secret=self.secret, evalex_trusted=is_trusted),
|
|
mimetype="text/html",
|
|
)
|
|
|
|
def get_resource(self, request: Request, filename: str) -> Response:
|
|
"""Return a static resource from the shared folder."""
|
|
filename = join("shared", basename(filename))
|
|
try:
|
|
data = pkgutil.get_data(__package__, filename)
|
|
except OSError:
|
|
data = None
|
|
if data is not None:
|
|
mimetype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
return Response(data, mimetype=mimetype)
|
|
return Response("Not Found", status=404)
|
|
|
|
def check_pin_trust(self, environ: "WSGIEnvironment") -> t.Optional[bool]:
|
|
"""Checks if the request passed the pin test. This returns `True` if the
|
|
request is trusted on a pin/cookie basis and returns `False` if not.
|
|
Additionally if the cookie's stored pin hash is wrong it will return
|
|
`None` so that appropriate action can be taken.
|
|
"""
|
|
if self.pin is None:
|
|
return True
|
|
val = parse_cookie(environ).get(self.pin_cookie_name)
|
|
if not val or "|" not in val:
|
|
return False
|
|
ts, pin_hash = val.split("|", 1)
|
|
if not ts.isdigit():
|
|
return False
|
|
if pin_hash != hash_pin(self.pin):
|
|
return None
|
|
return (time.time() - PIN_TIME) < int(ts)
|
|
|
|
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."""
|
|
exhausted = False
|
|
auth = False
|
|
trust = self.check_pin_trust(request.environ)
|
|
pin = t.cast(str, self.pin)
|
|
|
|
# If the trust return value is `None` it means that the cookie is
|
|
# set but the stored pin hash value is bad. This means that the
|
|
# pin was changed. In this case we count a bad auth and unset the
|
|
# cookie. This way it becomes harder to guess the cookie name
|
|
# instead of the pin as we still count up failures.
|
|
bad_cookie = False
|
|
if trust is None:
|
|
self._fail_pin_auth()
|
|
bad_cookie = True
|
|
|
|
# If we're trusted, we're authenticated.
|
|
elif trust:
|
|
auth = True
|
|
|
|
# If we failed too many times, then we're locked out.
|
|
elif self._failed_pin_auth > 10:
|
|
exhausted = True
|
|
|
|
# Otherwise go through pin based authentication
|
|
else:
|
|
entered_pin = request.args["pin"]
|
|
|
|
if entered_pin.strip().replace("-", "") == pin.replace("-", ""):
|
|
self._failed_pin_auth = 0
|
|
auth = True
|
|
else:
|
|
self._fail_pin_auth()
|
|
|
|
rv = Response(
|
|
json.dumps({"auth": auth, "exhausted": exhausted}),
|
|
mimetype="application/json",
|
|
)
|
|
if auth:
|
|
rv.set_cookie(
|
|
self.pin_cookie_name,
|
|
f"{int(time.time())}|{hash_pin(pin)}",
|
|
httponly=True,
|
|
samesite="None",
|
|
)
|
|
elif bad_cookie:
|
|
rv.delete_cookie(self.pin_cookie_name)
|
|
return rv
|
|
|
|
def log_pin_request(self) -> Response:
|
|
"""Log the pin if needed."""
|
|
if self.pin_logging and self.pin is not None:
|
|
_log(
|
|
"info", " * To enable the debugger you need to enter the security pin:"
|
|
)
|
|
_log("info", " * Debugger pin code: %s", self.pin)
|
|
return Response("")
|
|
|
|
def __call__(
|
|
self, environ: "WSGIEnvironment", start_response: "StartResponse"
|
|
) -> t.Iterable[bytes]:
|
|
"""Dispatch the requests."""
|
|
# important: don't ever access a function here that reads the incoming
|
|
# form data! Otherwise the application won't have access to that data
|
|
# any more!
|
|
request = Request(environ)
|
|
response = self.debug_application
|
|
if request.args.get("__debugger__") == "yes":
|
|
cmd = request.args.get("cmd")
|
|
arg = request.args.get("f")
|
|
secret = request.args.get("s")
|
|
frame = self.frames.get(request.args.get("frm", type=int)) # type: ignore
|
|
if cmd == "resource" and arg:
|
|
response = self.get_resource(request, arg) # type: ignore
|
|
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
|
|
elif (
|
|
self.evalex
|
|
and cmd is not None
|
|
and frame is not None
|
|
and self.secret == secret
|
|
and self.check_pin_trust(environ)
|
|
):
|
|
response = self.execute_command(request, cmd, frame) # type: ignore
|
|
elif (
|
|
self.evalex
|
|
and self.console_path is not None
|
|
and request.path == self.console_path
|
|
):
|
|
response = self.display_console(request) # type: ignore
|
|
return response(environ, start_response)
|
|
|