-15% na nową książkę sekuraka: Wprowadzenie do bezpieczeństwa IT. Przy zamówieniu podaj kod: 10000

PyScript – czyli Python w Twojej przeglądarce + co można nim zrobić?

05 maja 2022, 12:59 | Teksty | komentarzy 12

Kilka dni temu projekt Anaconda ogłosił framework PyScript – pozwalający na wykonywanie kodu Pythona bezpośrednio w przeglądarce oraz jego integrację z HTML-em czy kodem napisanym w JS.

Samo wykonywanie kodu Pythona w przeglądarce nie jest nowe; projekt pyodide pozwala na to już od dłuższego czasu (poprzez skompilowanie Pythona do postaci WebAssembly), ale nowinką jest tutaj integracja z resztą ekosystemu przeglądarkowego. Dzięki PyScriptowi możemy łatwo dołączać wiele modułów bezpośrednio z repozytorium pip: powinny działać wszystko moduły, które są napisane w „czystym” Pythonie, zaś niektóre wymagające natywnego kodu zostały również przekompilowane do WebAssembly.

Python ma bardzo dużą bibliotekę standardową, a także wiele świetnych zewnętrznych bibliotek, projekt ten otwiera więc ciekawe możliwości szybkiego budowania prostych narzędzi wspomagających np. testy bezpieczeństwa, czy też pomocnych przy pokazywaniu pewnych konceptów szkoleniowych.

Podstawy PyScripta

Zobaczmy bardzo prosty kod napisany w PyScript:

<!doctype html>
<html>
  <head>
    <script defer src="https://pyscript.net/alpha/pyscript.js"></script>
  </head>
  <body>
    <py-script>
from js import alert
alert("Sekurak pozdrawia!")
    </py-script>
  </body>
</html>

Jak widać, jedyne czego potrzebujemy, to załadowanie skryptu, a później w specjalnym tagu <py-script> możemy pisać kod pythonowy. W powyższym przykładzie od razu skorzystałem ze wbudowanego w PyScript modułu js, który pozwala nam bezpośrednio odnosić się do funkcji JavaScriptu. Jak zatem można się domyślić, po załadowaniu powyższej strony, wyświetli się alert o treści „Sekurak pozdrawia”.

Większa interakcja z JS

Spróbujmy może nieco większej interakcji z JS. Napiszmy prosty skrypt, który skorzysta z pythonowego modułu secrets do generacji losowych tokenów. Tokeny te zostaną wygenerowane po naciśnięciu przycisku, a wyświetlą się w innym elemencie HTML-owym. Oto przykładowa implementacja:

<!doctype html>
<html>
  <head>
    <script defer src="https://pyscript.net/alpha/pyscript.js"></script>
  </head>
  <body>
    <button>Kliknij mnie</button>
    <div>Losowy token to: <b id=token></b></div>
    <py-script>
from js import document,alert
from pyodide import create_proxy
import secrets

def onclick(ev):
  pyscript.write("token", secrets.token_hex(16))
  
button = document.querySelector("button")
button.addEventListener("click", create_proxy(onclick))
    </py-script>
  </body>
</html>

Zwróćmy uwagę na dwie istotne nowe fakty w tym kodzie:

  1. Jeśli chcemy, żeby kod JS mógł wykonywać funkcję zdefiniowaną w PyScript, to musimy ją opakować poprzez wywołanie create_proxy.
  2. PyScript udostępnia metodę pyscript.write, która pozwala przypisać HTML bezpośrednio do elementu o zadanym ID. Nie ma więc potrzeby odwoływania się do document.getElementById (choć można by to zrobić).

A poniżej zobaczmy kod w działaniu:

Losowy token to:
from js import document,alert from pyodide import create_proxy import secrets def onclick(ev): pyscript.write(chr(116)+chr(111)+chr(107)+chr(101)+chr(110), secrets.token_hex(16)) button = document.querySelector(chr(98)+chr(117)+chr(116)+chr(116)+chr(111)+chr(110)) button.addEventListener(chr(99)+chr(108)+chr(105)+chr(99)+chr(107), create_proxy(onclick))

Importowanie modułów z pip

O ile wykorzystanie modułów z biblioteki standardowej kończy się na użyciu zwykłego import, o tyle pewne dodatkowe kroki trzeba poczynić jeśli chcemy załadować moduł z repozytorium pip.

Mianowicie: należy dodać w HTML-u specjalny tag <py-env>, w którym definiujemy listę zewnętrznych modułów. Przykładowo, gdybyśmy chcieli zaimportować primefac, to musimy wczesniej użyć tagu:

<py-env>
 - primefac
</py-env>

I w dalszym kodzie zwykły import primefac już zadziała.

primefac to biblioteka przydatna przy rozkładaniu liczb na czynniki pierwsze. Napiszmy więc bardzo prosty skrypt, który wykorzysta ją do rozkładu na czynniki pierwsze liczby podanej przez użytkownika. Oto kod:

<!doctype html>
<script defer src="https://pyscript.net/alpha/pyscript.js"></script>
<py-env>
  - primefac
</py-env>
<p><label>Podaj liczbę całkowitą z zakresu od 1 do 10000: <input id=n></label></p>
<p>Czynniki pierwsze to: <span id=factors></span></p>
<py-script>
import primefac
from pyodide import create_proxy

input = document.querySelector("#n")
def oninput(ev):
  num = int(ev.target.value)
  num = min(num, 10000)
  num = max(num, 1)
  factors = list(primefac.primefac(num))
  pyscript.write("factors", factors)

input.addEventListener("input", create_proxy(oninput))

</py-script>

A oto kod w działaniu:

Czynniki pierwsze to:

Znając podstawy pokazane w powyższych przykładach możemy spróbować wykorzystać bardziej praktycznie PyScript.

Przykład #1: testy sanitizera bleach

Bleach to pythonowa biblioteka rozwijana przez Mozillę, służąca do sanityzacji (czyszczenia) kodu HTML ze złośliwych elementów lub atrybutów, pozwalających na wykorzystanie podatności XSS. Osobiście bardzo lubię testować sanitizery, a jednym z najwygodniejszych na to sposobów jest napisanie kodu, który będzie wykonywał sanityzację na biężąco, tj. bezpośrednio po wciskaniu kolejnych klawiszy.

Z użyciem PyScript, napisanie takiego środowiska jest bardzo proste:

<py-env>
  - bleach
</py-env>
<p><label>Podaj HTML<textarea id=bleach_input></textarea></label></p>
<p><strong>HTML po sanityzacji</strong></p>
<pre wrap id=bleach_output></pre>
<py-script>
from js import document
from pyodide import create_proxy
import bleach

bleach_input = document.getElementById("bleach_input")
bleach_output = document.getElementById("bleach_output")

def onbleach(ev):
  html = ev.target.value
  sanitized = bleach.clean(html)
  bleach_output.textContent = sanitized

bleach_input.addEventListener("input", create_proxy(onbleach))

</py-script>

HTML po sanityzacji



Jako ciekawostkę dodam, że po szybkim testowaniu bleach z wykorzystaniem powyższego kodu mam prawdopodobne obejście sanitizera (aczkolwiek przy niestandardowej konfiguracji). Widać więc, że zbudowanie odpowiedniego środowiska do testów ułatwia znajdowanie błędów bezpieczeństwa.

Przykład #2: kryptografia (+ generowanie kodów QR)

W Pythonie istnieje bardzo przydatna biblioteka nazywająca się po prostu cryptography, pozwalająca na wykonywanie w zasadzie dowolnych operacji kryptograficznych (szyfrowanie, podpisywanie, generowanie kodów jednorazowych, haszowanie itp.). Nie jest ona napisana w „czystym” Pythonie, ale jest jedną z tych bibliotek, która jest przekompilowana specjalnie na potrzeby pyodide (a zarazem i PyScript).

Postanowiłem w ramach treningu spróbować wykorzystać tę bibliotekę i napisać z jej użyciem symulator Google Authenticatora. Mogę to łatwo zrobić, ponieważ cryptography ma gotowy moduł do generowania kodów jednorazowych z użyciem algorytmu TOTP.

Plan mam więc taki, by kod był w stanie:

  1. Generować losowe klucze do Google Authenticatora
  2. Generować kody QR, które można łatwo zaimportować przez Authenticatora
  3. Generować kolejne kody jednorazowe (powinny być identyczne jak w aplikacji)

Przyjrzyjmy się każdemu z tych elementów z osobna.

Po pierwsze, klucze w Authenticatorze mają długość 80 bitów (10 bajtów) i są zakodowane w Base32. Wyglądają przykładowo tak: 7NWLVFIDJSXOVSSV. Wygenerowanie ich wymaga po prostu użycia bezpiecznego kryptograficznie generatora liczb pseudolosowych, np. z modułu secrets:

import secrets
def get_random_key():
  return secrets.token_bytes(10)

W kodzie nie enkoduje danych do Base32, ponieważ później biblioteka cryptography i tak zrobi to sama.

Krok drugi, czyli generowanie kodów QR, będzie wymagało użycia kolejnej biblioteki, mianowicie: qrcode oraz pillow (wykorzystywana przez qrcode do generacji obrazków).

Co ciekawe, PyScript przewiduje możliwość łatwego wyświetlania obrazków wygenerowanych w naszym kodzie, ale – niestety – obecnie ten kod ma błąd i nie wyświetla ich poprawnie. Na szczęście można go łatwo naprawić, definiując własną metodę render_image, która wygeneruje poprawny obrazek.

Bloki kodu PyScript są też interpretowane w taki sposób, że ostatnia linia w naszym kodzie jest traktowana jako wartość zwracana przez ten blok. Jeśli więc w ostatniej linii umieścimy po prostu referencję do obrazka – PyScript uzna, że chcemy ten obrazek wyświetlić.

Poniżej przykład kodu, który po prostu wyświetla kod QR z tekstem:

<py-env>
  - qrcode
  - pillow
</py-env>
<py-script>
import qrcode
import base64 

# Naprawiam oryginalną zepsutą metodę render_image
def render_image(mime, value, meta):
  data = f"data:{mime};base64,{base64.b64encode(value).decode('utf-8')}"
  return f"\u003cimg src='{data}'\u003e"

img = qrcode.make("Witaj Sekuraku!")

# Zwracam referencję do obrazka w ostatniej linii kodu
img
</py-script>

I efekt:

Oczywiście, żeby Google Authenticator mógł przyjąć kod QR, musi on zawierać odpowiednią treść. Jak możemy dowiedzieć się z dokumentacji format jest następujący:

otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example

Nie będziemy musieli jednak sami przygotowywać takiego formatu URL-a, bowiem cryptography ma do tego gotową metodę nazywającą się get_provisioning_uri.

Przejdźmy więc do ostatniego kroku, czyli generacji kodów jednorazowych. Poniżej przykład kodu, który wygeneruje kolejny kod jednorazowy dla zadanego czasu i zadanego klucza:

import os
import time
from cryptography.hazmat.primitives.twofactor.totp import TOTP
from cryptography.hazmat.primitives.hashes import SHA1

# Losujemy klucz
key = os.urandom(20)
# Definiujemy długość TOTP na 6 znaków i regenerację
# co 30 sekund
totp = TOTP(key, 6, SHA1(), 30)
time_value = time.time()
# Generujemy kod dla zadanego czasu
totp_value = totp.generate(time_value)

Gdy już mamy obiekt totp to właśnie na nim możemy wykonać wcześniej wspomnianą metodę get_provisioning_uri.

Połączmy zatem wszystko w jedną całość. Oto kod:

<div style="border: 1px solid black;padding: 1em; font-family:monospace">

<p><strong>Klucz TOTP</strong>: <span id=otpkey></span></p>
<p><strong>Kod jednorazowy</strong>:</p>
<p><span style=font-size:xxx-large;font-weight:bold id=otpotp>123</span></p>
<p><strong>Następny kod za</strong>: <span id=next_code_in></span>s</p>
<p><button id=newotpkey>Wygeneruj nowy klucz</button></p>
<div><img id=otpqr style=max-width:320px></div>
<py-env>
  - qrcode
  - pillow
  - cryptography
</py-env>
<py-script>
import qrcode
import base64
import secrets
from cryptography.hazmat.primitives.twofactor.totp import TOTP
from cryptography.hazmat.primitives.hashes import SHA1
from js import setInterval, document
from pyodide import create_proxy
from io import BytesIO

OTP_LENGTH = 6
OTP_INTERVAL = 30

state = {
  "key": b"",
  "otp": "",
  "totp_object": None,
  "qrcode": None
}

# Regeneracja klucza i tworzenie obiektu TOTP
def regenerate_key(arg=None):
  global state
  state["key"] = secrets.token_bytes(10)
  state["totp_object"] =  TOTP(state["key"], OTP_LENGTH, SHA1(), OTP_INTERVAL, enforce_key_length=False)
  uri = state["totp_object"].get_provisioning_uri("PyScript", "Sekurak")
  img = qrcode.make(uri)
  buf = BytesIO()
  img.save(buf, format="png")
  imgdata = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode('utf-8')}"
  state["qrcode"] = imgdata
  
  generate_otp()

def generate_otp():
  global state
  state["otp"] = state["totp_object"].generate(time.time())

qrcode_image = document.getElementById("otpqr")

# Funkcja wywoływana co sekundę, której celem aktualizacja stanu w HTML
def update_html():
  global state
  generate_otp()
  next_code_in = OTP_INTERVAL - time.time() % OTP_INTERVAL
  pyscript.write("otpkey", base64.b32encode(state["key"]).decode('utf-8'))
  pyscript.write("otpotp", state["otp"].decode('utf-8'))
  pyscript.write("next_code_in", round(next_code_in))
  qrcode_image.src = state["qrcode"]

newotpbutton = document.getElementById("newotpkey")
newotpbutton.addEventListener("click", create_proxy(regenerate_key))

setInterval(create_proxy(update_html), 1000)

regenerate_key()
update_html()
</py-script>
</div>

I wynik działania:

Klucz TOTP:

Kod jednorazowy:

123

Następny kod za: s

Najlepszym sposobem na sprawdzenie czy skrypt działa poprawnie, było zaimportowanie klucza QR do Authenticatora i weryfikacja, czy generuje te same kody, co skrypt. Jak pokazuje poniższe zdjęcie – wygląda na to, że tak!

Przykład #3: sqlite

Ostatni przykład, jaki pokażę w tym tekście będzie używał sqlite. Moduł do obsługi sqlite znajduje się w bibliotece standardowej Pythona i jest dostępny z poziomy PyScriptu. Pomyślałem więc, że można by go wykorzystać jako demo SQL Injection na szkoleniach.

Zbuduję więc bardzo prosty skrypt, który:

  1. Utworzy bazę sqlite.
  2. Utworzy w bazie dwie tabele; jedną z danymi produktów, drugą z danymi użytkowników.
  3. Udostępni interfejs, w którym użytkownik może wpisać frazę do wyszukiwania. To miejsce będzie podatne na SQL Injection.
  4. Celem będzie wydobycie danych z drugiej tabeli.

Tutaj raczej dodatkowy opis nie jest potrzebny, przejdźmy od razu do kodu:

<p><label>Podaj frazę: <input id=searchphrase oninput="search_sql(event.target.value)"></label></p>
<p><strong>Wykonany SQL:</strong></p>
<pre wrap id=sqlsql></pre>
<p><strong>Wynik:</strong></p>
<pre wrap id=sqlresult></pre>
<py-script>
import sqlite3
from js import window, document
from pyodide import create_proxy

# Tworzymy bazę sqlite w pamięci
con = sqlite3.connect(":memory:")
cur = con.cursor()
products = [
  ("Produkt 1", "Opis 1", 12),
  ("Produkt 2", "Opis 2", 33),
  ("Produkt 3", "Opis 3", 34),
]
users = [
  ("mb", "bardzo-tajne-haslo"),
  ("ms", "jeszcze-tajniejsze-haslo")
]

cur.execute("CREATE TABLE products (name, description, amount)")
cur.execute("CREATE TABLE users (name, description)")

cur.executemany("INSERT INTO products VALUES (?, ?, ?)", products)
cur.executemany("INSERT INTO users VALUES (?, ?)", users)

sqlsql = document.getElementById("sqlsql")
sqlresult = document.getElementById("sqlresult")

def search(query):
  sql = f"SELECT * FROM products WHERE name LIKE '%{query}%' OR description LIKE '%{query}%'"
  sqlsql.textContent = sql
  try:
    cur.execute(sql)
    result = cur.fetchall()
  except Exception as ex:
    result = str(ex)

  sqlresult.textContent = result
  
window.search_sql = create_proxy(search)

</py-script>

I wyniku:

Wykonany SQL:


Wynik:



Oczywiście, gdyby tego typu skrypt miał faktycznie być użyty na szkoleniu, to musiałby wyglądać lepiej i przedstawiać więcej informacji użytkownikowi, ale sam ten przykład powinien już pokazywać jak łatwo można go przygotować.

Podsumowanie

PyScript jest nowym, ciekawym projektem, pozwalającym na wykonywanie własnego kodu Pythona bezpośrednio w przeglądarce. Na razie jest jeszcze we wczesnym stadium i nietrudno jest znaleźć w nim elementy, które nie działają, ale tak czy owak ma przed sobą spory potencjał. Z mojego punktu widzenia – szczególnie ciekawie wygląda pod kątem szkoleniowym, jak również przygotowywania prostych narzędzi ułatwiających wykonywanie codziennych czynności, korzystając z rozbudowanej biblioteki standardowej Pythona.

— Michał Bentkowski

Spodobał Ci się wpis? Podziel się nim ze znajomymi:



Komentarze

  1. imię

    komentarz jest taki, że w żadnej przeglądarce (opera,ff,vivaldi) nie działa żaden przykład. czyli fajne, ale bezużyteczne

    Odpowiedz
    • Michał Bentkowski

      Problem wygenerował niestety WordPress, a nie sam PyScript, ze względu na automatyczną zamianę niektórych znaków w kodzie. Teraz powinno być już okej.

      Odpowiedz
  2. Dorian

    Access to fetch at 'https://pypi.org/pypi/r/json’ from origin 'https://sekurak.pl’ has been blocked by CORS policy: No 'Access-Control-Allow-Origin’ header is present on the requested resource. If an opaque response serves your needs, set the request’s mode to 'no-cors’ to fetch the resource with CORS disabled.

    Odpowiedz
  3. czesław

    Fajne, działa i można szybko wdrożyć.
    Ale tak jak zrozumiałem działanie tego i po tym co widzę w źródle tak wygenerowanej strony to nie można tego wykorzystać np. do połączenia z SQL, ponieważ widoczny jest cały kod

    Odpowiedz
    • Michał Bentkowski

      W moim przykładzie nie chodziło mi o łączenie do zewnętrznej bazy SQL – tylko o możliwość zobaczenia bezpośrednio w przeglądarce w jaki sposób wpisywany tekst wpływa na wykonywane zapytanie SQL. Raczej jako prezentacja, aniżeli jako wyzwanie czy zadanie dla uczestników.

      Odpowiedz
      • czesław

        Spoko.
        Ja akurat szukam od wczoraj hostingu pod pythona i pojawił się Twój artukuł. Myślałem, że to łatwy sposób na używanie pythona tam gdzie jest tylko php.

        Postawiłem na swoim obecnym hostingu prosty skrypt z pętelką i printem i zadziałało.

        Ale nie tego szukam. Pozdrawiam

        Odpowiedz
    • Johhon

      Bo masz bardzo powszechnie stosowany, ale bardzo zły szablon dostępu do SQL, i właśnie dlatego bardzo często dane wyciekają.

      Autentykacja powinna być robiona na konkretnego usera portalu, a potem komunikacja z serwisem za pomocą tagu będącego kluczem autentykacji.

      ale to dla większości devów, jest kompletnie niezrozumiałe, zwłaszcza tych którzy robią w PHP.

      Odpowiedz
  4. Darek

    Brython działa dużo lepiej i jest obsługiwany przez większość przeglądarek (z tego co widzę to dużo ludzi narzeka że PyScript nie działa na ich przeglądarkach). Jeśli ktoś potrzebuje pythona, który będzie wykonywany po stronie klienta, w przeglądarce, to myślę że brython będzie lepszym rozwiązaniem.

    Odpowiedz
    • Michał Bentkowski

      Dzięki, nie słyszałem wcześniej o Brythonie. Z tego co widzę na szybko, to niestety nie daje rady tak łatwo importować zewnętrznych modułów. Ale prędkość działania wydaje się rzeczywiście lepsza niż w PyScript.

      Odpowiedz
  5. Ciekawa

    Rozwiązanie interesujące, ale strony psują się, jak jest zablokowane wykonywanie JavaScript spoza głównej domeny. Zawsze można ściągnąć z CDNa i hostować u siebie, co jest bezpieczniejszą i zalecaną opcją.

    Odpowiedz
  6. Jarosław

    I na taki artykuł o Pythonie czekałem <3

    Odpowiedz
  7. Kuba

    oj ale jak sie evala wsstrzeli w taka ramke to bedzie szal :P

    Odpowiedz

Odpowiedz na Johhon