Skip to content

Commit

Permalink
Merge pull request #1039 from keredson/ssl-proof-of-concept
Browse files Browse the repository at this point in the history
SSL support for internal server
  • Loading branch information
r0x0r authored Mar 22, 2023
2 parents eb4461d + df72ac1 commit 9257c25
Show file tree
Hide file tree
Showing 9 changed files with 170 additions and 8 deletions.
3 changes: 2 additions & 1 deletion docs/guide/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ server=http.BottleServer, server_args
``` python
webview.start(func=None, args=None, localization={}, gui=None, debug=False, http_server=False,
http_port=None, user_agent=None, private_mode=True, storage_path=None, menu=[],
server=http.BottleServer, server_args={}):
server=http.BottleServer, ssl=False, server_args={}):
```

Start a GUI loop and display previously created windows. This function must be called from a main thread.
Expand All @@ -64,6 +64,7 @@ Start a GUI loop and display previously created windows. This function must be c
* `storage_path` - An optional location on hard drive where to store persistant objects. By default `~/.pywebview` is used on *nix systems and `%APPDATA%\pywebview` on Windows.
* `menu` - Pass a list of Menu objects to create an application menu. See [this example](/examples/menu.html) for usage details.
* `server` - A custom WSGI server instance. Defaults to BottleServer.
* `ssl` - If using the default BottleServer (and for now the GTK backend), will use SSL encryption between the webview and the internal server.
* `server_args` - Dictionary of arguments to pass through to the server instantiation

### Examples
Expand Down
7 changes: 7 additions & 0 deletions examples/localhost_ssl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import webview

if __name__ == '__main__':
webview.create_window('Local SSL Test', 'assets/index.html')
gui = None
webview.start(gui=gui, ssl=True)

3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ pyqtwebengine; sys_platform == "openbsd6" or sys_platform == "linux"
QtPy; sys_platform == "openbsd6" or sys_platform == "linux"
importlib_resources; python_version < "3.7"
proxy_tools
bottle
bottle
cryptography
74 changes: 72 additions & 2 deletions webview/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
"""


import datetime
import logging
import os
import re
import tempfile
import threading
from uuid import uuid4
from proxy_tools import module_property
Expand Down Expand Up @@ -71,7 +73,8 @@
menus = []

def start(func=None, args=None, localization={}, gui=None, debug=False, http_server=False,
http_port=None, user_agent=None, private_mode=True, storage_path=None, menu=[], server=http.BottleServer, server_args={}):
http_port=None, user_agent=None, private_mode=True, storage_path=None, menu=[],
server=http.BottleServer, server_args={}, ssl=False):
"""
Start a GUI loop and display previously created windows. This function must
be called from a main thread.
Expand Down Expand Up @@ -127,6 +130,14 @@ def _create_children(other_windows):
raise WebViewException('You must create a window first before calling this function.')

guilib = initialize(gui)

if ssl:
# generate SSL certs and tell the windows to use them
keyfile, certfile = generate_ssl_cert()
server_args['keyfile'] = keyfile
server_args['certfile'] = certfile
else:
keyfile, certfile = None, None

urls = [w.original_url for w in windows]
has_local_urls = not not [
Expand All @@ -139,11 +150,15 @@ def _create_children(other_windows):
(http_server or has_local_urls or (guilib.renderer == 'gtkwebkit2')):
if not _private_mode and not http_port:
http_port = DEFAULT_HTTP_PORT
prefix, common_path, server = http.start_global_server(http_port=http_port, urls=urls, server=server, **server_args)
prefix, common_path, server = http.start_global_server(http_port=http_port, urls=urls, server=server, ssl=ssl, **server_args)

for window in windows:
window._initialize(guilib)

if ssl:
for window in windows:
window.gui.add_tls_cert(certfile)

if len(windows) > 1:
t = threading.Thread(target=_create_children, args=(windows[1:],))
t.start()
Expand All @@ -160,6 +175,9 @@ def _create_children(other_windows):
if menu:
guilib.set_app_menu(menu)
guilib.create_window(windows[0])
# keyfile is deleted by the ServerAdapter right after wrap_socket()
if certfile:
os.unlink(certfile)


def create_window(title, url=None, html=None, js_api=None, width=800, height=600, x=None, y=None,
Expand Down Expand Up @@ -217,6 +235,58 @@ def create_window(title, url=None, html=None, js_api=None, width=800, height=600
guilib.create_window(window)

return window

def generate_ssl_cert():
# https://cryptography.io/en/latest/x509/tutorial/#creating-a-self-signed-certificate
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes

with tempfile.NamedTemporaryFile(prefix='keyfile_', suffix='.pem', delete=False) as f:
keyfile = f.name
key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(), #BestAvailableEncryption(b"passphrase"),
)
f.write(key_pem)


with tempfile.NamedTemporaryFile(prefix='certfile_', suffix='.pem', delete=False) as f:
certfile = f.name
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, u"US"),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"California"),
x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"pywebview"),
x509.NameAttribute(NameOID.COMMON_NAME, u"127.0.0.1"),
])
cert = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
issuer
).public_key(
key.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
datetime.datetime.utcnow()
).not_valid_after(
datetime.datetime.utcnow() + datetime.timedelta(days=365)
).add_extension(
x509.SubjectAlternativeName([x509.DNSName(u"localhost")]),
critical=False,
).sign(key, hashes.SHA256())
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
f.write(cert_pem)

return keyfile, certfile

def active_window():
"""
Expand Down
56 changes: 52 additions & 4 deletions webview/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import os
import threading
import random
import ssl
import socket
import uuid
from wsgiref.simple_server import make_server, WSGIRequestHandler, WSGIServer
Expand Down Expand Up @@ -69,7 +70,7 @@ def __init__(self):
self.uid = str(uuid.uuid1())

@classmethod
def start_server(self, urls, http_port):
def start_server(self, urls, http_port, keyfile=None, certfile=None):
from webview import _debug

apps = [u for u in urls if is_app(u)]
Expand Down Expand Up @@ -108,11 +109,19 @@ def asset(file):

server.root_path = abspath(common_path) if common_path is not None else None
server.port = http_port or _get_random_port()
server.thread = threading.Thread(target=lambda: bottle.run(app=app, server=ThreadedAdapter, port=server.port, quiet=not _debug['mode']), daemon=True)
if keyfile and certfile:
server_adapter = SSLWSGIRefServer()
server_adapter.port = server.port
setattr(server_adapter, 'pywebview_keyfile', keyfile)
setattr(server_adapter, 'pywebview_certfile', certfile)
else:
server_adapter = ThreadedAdapter
server.thread = threading.Thread(target=lambda: bottle.run(app=app, server=server_adapter, port=server.port, quiet=not _debug['mode']), daemon=True)
server.thread.start()

server.running = True
server.address = f'http://127.0.0.1:{server.port}/'
protocol = 'https' if keyfile and certfile else 'http'
server.address = f'{protocol}://127.0.0.1:{server.port}/'
self.common_path = common_path
server.js_api_endpoint = f'{server.address}js_api/{server.uid}'

Expand All @@ -122,13 +131,52 @@ def asset(file):
def is_running(self):
return self.running

class SSLWSGIRefServer(bottle.ServerAdapter):
def run(self, app): # pragma: no cover
from wsgiref.simple_server import make_server
from wsgiref.simple_server import WSGIRequestHandler, WSGIServer
import socket

class FixedHandler(WSGIRequestHandler):
def address_string(self): # Prevent reverse DNS lookups please.
return self.client_address[0]

def log_request(*args, **kw):
if not self.quiet:
return WSGIRequestHandler.log_request(*args, **kw)

handler_cls = self.options.get('handler_class', FixedHandler)
server_cls = self.options.get('server_class', WSGIServer)

if ':' in self.host: # Fix wsgiref for IPv6 addresses.
if getattr(server_cls, 'address_family') == socket.AF_INET:

class server_cls(server_cls):
address_family = socket.AF_INET6

self.srv = make_server(self.host, self.port, app, server_cls,
handler_cls)
context = ssl.create_default_context()
self.srv.socket = ssl.wrap_socket(
self.srv.socket,
keyfile=self.pywebview_keyfile,
certfile=self.pywebview_certfile,
server_side=True)
self.port = self.srv.server_port # update port actual port (0 means random)
os.unlink(self.pywebview_keyfile)
try:
self.srv.serve_forever()
except KeyboardInterrupt:
self.srv.server_close() # Prevent ResourceWarning: unclosed socket
raise


def start_server(urls, http_port=None, server=BottleServer, **server_args):
server = server if not server is None else BottleServer
return server.start_server(urls, http_port, **server_args)


def start_global_server(http_port=None, urls='.', server=BottleServer, **server_args):
def start_global_server(http_port=None, urls='.', server=BottleServer, ssl=False, **server_args):
global global_server
address, common_path, global_server = start_server(urls=urls, http_port=http_port, server=server, **server_args)
return address, common_path, global_server
Expand Down
14 changes: 14 additions & 0 deletions webview/platforms/cocoa.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ def webView_runJavaScriptAlertPanelWithMessage_initiatedByFrame_completionHandle
handler.__block_signature__ = BrowserView.pyobjc_method_signature(b'v@')
handler()

def webView_didReceiveAuthenticationChallenge_completionHandler_(self, webview, challenge, handler):
# Prevent `ObjCPointerWarning: PyObjCPointer created: ... type ^{__SecTrust=}`
from Security import SecTrustRef

# this allows any server cert
credential = AppKit.NSURLCredential.credentialForTrust_(challenge.protectionSpace().serverTrust())
handler(AppKit.NSURLSessionAuthChallengeUseCredential, credential)

# Display a JavaScript confirm panel containing the specified message
def webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler_(self, webview, message, frame, handler):
i = BrowserView.get_instance('webkit', webview)
Expand Down Expand Up @@ -1102,3 +1110,9 @@ def get_screens():
screens = [Screen(s.frame().size.width, s.frame().size.height) for s in AppKit.NSScreen.screens()]
return screens


def add_tls_cert(certfile):
# does not auth against the certfile
# see webView_didReceiveAuthenticationChallenge_completionHandler_
pass

6 changes: 6 additions & 0 deletions webview/platforms/gtk.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,12 @@ def _toggle_fullscreen():
glib.idle_add(_toggle_fullscreen)


def add_tls_cert(certfile):
web_context = webkit.WebContext.get_default()
cert = Gio.TlsCertificate.new_from_file(certfile)
web_context.allow_tls_certificate_for_host(cert, '127.0.0.1')


def set_on_top(uid, top):
def _set_on_top():
BrowserView.instances[uid].window.set_keep_above(top)
Expand Down
11 changes: 11 additions & 0 deletions webview/platforms/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@
try:
from qtpy.QtWebEngineWidgets import QWebEngineView as QWebView, QWebEnginePage as QWebPage, QWebEngineProfile
from qtpy.QtWebChannel import QWebChannel
from qtpy.QtNetwork import QSslConfiguration, QSslCertificate
renderer = 'qtwebengine'
is_webengine = True
except ImportError:
from PyQt5 import QtWebKitWidgets
from PyQt5.QtWebKitWidgets import QWebView, QWebPage
from PyQt5.QtNetwork import QSslConfiguration, QSslCertificate
is_webengine = False
renderer = 'qtwebkit'

Expand Down Expand Up @@ -913,3 +915,12 @@ def get_screens():
screens = [Screen(g.width(), g.height()) for g in geometries]

return screens

def add_tls_cert(certfile):
config = QSslConfiguration.defaultConfiguration()
certs = config.caCertificates()
cert = QSslCertificate.fromPath(certfile)[0]
certs.append(cert)
config.setCaCertificates(certs)
QSslConfiguration.setDefaultConfiguration(config)

4 changes: 4 additions & 0 deletions webview/platforms/winforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,3 +768,7 @@ def get_size(uid):
def get_screens():
screens = [Screen(s.Bounds.Width, s.Bounds.Height) for s in WinForms.Screen.AllScreens]
return screens

def add_tls_cert(certfile):
raise NotImplementedError

0 comments on commit 9257c25

Please sign in to comment.