#!/usr/bin/env python3 """ PoC: GlobalProtect SAML-POST (Duo) auf macOS rootlos. Holt den GP-Prelogin, fuehrt das Duo-SAML in einem eingebetteten WKWebView durch, greift den `prelogin-cookie`/`saml-username` aus den HTTP-Antwort-Headern ab (was QtWebEngine nicht kann, das native WKWebView ueber den Navigation-Response- Delegate aber schon) und startet damit openconnect rootlos ueber ocproxy. Start: /Library/Developer/CommandLineTools/usr/bin/python3 ~/apps/heron_gp_saml.py """ from __future__ import annotations import base64 import json import os import re import shutil import subprocess import sys import urllib.request import objc from Cocoa import ( NSApplication, NSObject, NSWindow, NSURL, NSMakeRect, NSBackingStoreBuffered, ) from Foundation import NSHTTPURLResponse, NSHTTPCookie from WebKit import WKWebView, WKWebViewConfiguration SERVER = "ssl.heron.at" PORT = 11090 GP_UA = "PAN GlobalProtect" # GP liefert den prelogin-cookie nur dem Client-UA NSWindowStyleMaskTitled = 1 NSWindowStyleMaskClosable = 2 NSWindowStyleMaskResizable = 8 WKNavigationResponsePolicyAllow = 1 captured: dict[str, str] = {} def _tool(n: str) -> str: return shutil.which(n) or ( f"/opt/homebrew/bin/{n}" if os.path.exists(f"/opt/homebrew/bin/{n}") else n ) def _hipreport() -> str | None: import glob for p in ["/opt/homebrew/opt/openconnect/libexec/openconnect/hipreport.sh", *glob.glob("/opt/homebrew/Cellar/openconnect/*/libexec/openconnect/hipreport.sh"), "/usr/local/opt/openconnect/libexec/openconnect/hipreport.sh"]: if os.path.exists(p): return p return None def prelogin() -> tuple[str, str]: """Liefert (saml_html, sessid).""" # Gateway-Prelogin direkt (nicht das Portal) -> liefert den Gateway-Cookie. url = (f"https://{SERVER}/ssl-vpn/prelogin.esp" "?tmp=tmp&clientVer=4100&clientos=Mac") req = urllib.request.Request(url, method="POST", data=b"", headers={"User-Agent": GP_UA}) with urllib.request.urlopen(req, timeout=20) as r: xml = r.read().decode("utf-8", "replace") sessid = "" for c in r.headers.get_all("Set-Cookie") or []: m = re.search(r"SESSID=([^;]+)", c) if m: sessid = m.group(1) m = re.search(r"(.*?)", xml, re.S) if not m: raise SystemExit("Kein im Prelogin.") return base64.b64decode(m.group(1)).decode("utf-8", "replace"), sessid class Delegate(NSObject): def webView_decidePolicyForNavigationAction_decisionHandler_( self, webView, navAction, handler): u = str(navAction.request().URL().absoluteString()) print("NAV ->", u[:120], file=sys.stderr) if u.startswith("globalprotectcallback:"): payload = u[len("globalprotectcallback:"):] try: dec = base64.b64decode(payload).decode("utf-8", "replace") except Exception: dec = payload print("CALLBACK-Payload:", dec[:500], file=sys.stderr) for key in ("prelogin-cookie", "saml-username", "portal-userauthcookie"): m = re.search(rf"{key}[=:>]+\s*([^<&\"']+)", dec) if m: captured[key] = m.group(1).strip() handler(0) # Cancel if "prelogin-cookie" in captured: NSApplication.sharedApplication().stop_(None) return handler(1) # Allow def webView_decidePolicyForNavigationResponse_decisionHandler_( self, webView, navResponse, handler): resp = navResponse.response() if resp.isKindOfClass_(NSHTTPURLResponse): for k, v in resp.allHeaderFields().items(): lk = str(k).lower() if lk in ("prelogin-cookie", "saml-username", "portal-userauthcookie", "saml-auth-status"): captured[lk] = str(v) handler(WKNavigationResponsePolicyAllow) if "prelogin-cookie" in captured and "saml-username" in captured: NSApplication.sharedApplication().stop_(None) def webView_didFinishNavigation_(self, webView, nav): # Nur die SAML-Weiterleitungsform (id=myform) anstossen, sonst wuerde # auch das Duo-Login-Formular dauernd neu abgesendet (Flackern). webView.evaluateJavaScript_completionHandler_( "var f=document.getElementById('myform'); if(f){f.submit();}", None) def run_saml(html: str, sessid: str): app = NSApplication.sharedApplication() app.setActivationPolicy_(0) # Regular -> Fenster bekommt sauber den Fokus rect = NSMakeRect(0, 0, 540, 720) style = (NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskResizable) win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( rect, style, NSBackingStoreBuffered, False) win.setTitle_("Heron SSO-Login") cfg = WKWebViewConfiguration.alloc().init() wv = WKWebView.alloc().initWithFrame_configuration_(rect, cfg) wv.setCustomUserAgent_(GP_UA) delegate = Delegate.alloc().init() wv.setNavigationDelegate_(delegate) win.setContentView_(wv) win.makeKeyAndOrderFront_(None) base = NSURL.URLWithString_(f"https://{SERVER}/") def load(_=None): wv.loadHTMLString_baseURL_(html, base) if sessid: store = cfg.websiteDataStore().httpCookieStore() cookie = NSHTTPCookie.cookieWithProperties_({ "Name": "SESSID", "Value": sessid, "Domain": SERVER, "Path": "/", "Secure": "TRUE", }) store.setCookie_completionHandler_(cookie, load) else: load() app.activateIgnoringOtherApps_(True) app.run() def main(): auth_only = "--auth-only" in sys.argv html, sessid = prelogin() run_saml(html, sessid) if "prelogin-cookie" not in captured: print(f"Kein prelogin-cookie erhalten. Erfasst: {list(captured)}", file=sys.stderr) raise SystemExit(2) if auth_only: # Nur den Cookie als JSON auf stdout -> die Tray-App liest ihn. print(json.dumps({ "prelogin-cookie": captured["prelogin-cookie"], "saml-username": captured.get("saml-username", ""), })) return print("Erfasst:", {k: v[:20] + "…" for k, v in captured.items()}, file=sys.stderr) cmd = [ _tool("openconnect"), "--protocol=gp", "--user", captured.get("saml-username", ""), "--usergroup=gateway:prelogin-cookie", "--passwd-on-stdin", "--script", f"{_tool('ocproxy')} -D {PORT}", "--script-tun", ] hip = _hipreport() if hip: cmd += ["--csd-wrapper", hip] # HIP-Report, sonst trennt das Gateway cmd.append(SERVER) print("Starte:", " ".join(cmd), file=sys.stderr) p = subprocess.Popen(cmd, stdin=subprocess.PIPE) p.stdin.write((captured["prelogin-cookie"] + "\n").encode()) p.stdin.flush() p.wait() if __name__ == "__main__": main()