diff --git a/builder/pwnagotchi.yml b/builder/pwnagotchi.yml index 0164502..f369014 100644 --- a/builder/pwnagotchi.yml +++ b/builder/pwnagotchi.yml @@ -99,6 +99,9 @@ - bc - fonts-freefont-ttf - fbi + - python3-flask + - python3-flask-cors + - python3-flaskext.wtf tasks: - name: change hostname diff --git a/pwnagotchi/plugins/default/example.py b/pwnagotchi/plugins/default/example.py index 3aa5c7d..5a13651 100644 --- a/pwnagotchi/plugins/default/example.py +++ b/pwnagotchi/plugins/default/example.py @@ -15,6 +15,11 @@ class Example(plugins.Plugin): def __init__(self): logging.debug("example plugin created") + # called when http://<host>:<port>/plugins/<plugin>/ is called + # must return a response + def on_webhook(self, path, args, req_method): + pass + # called when the plugin is loaded def on_loaded(self): logging.warning("WARNING: this plugin should be disabled! options = " % self.options) diff --git a/pwnagotchi/ui/web.py b/pwnagotchi/ui/web.py index 3f1dc48..ee56f9d 100644 --- a/pwnagotchi/ui/web.py +++ b/pwnagotchi/ui/web.py @@ -1,6 +1,6 @@ import re import _thread -from http.server import BaseHTTPRequestHandler, HTTPServer +import secrets from threading import Lock import shutil import logging @@ -8,6 +8,13 @@ import logging import pwnagotchi from pwnagotchi.agent import Agent from pwnagotchi import plugins +from flask import Flask +from flask import send_file +from flask import request +from flask import abort +from flask import render_template_string +from flask_cors import CORS +from flask_wtf.csrf import CSRFProtect frame_path = '/root/pwnagotchi.png' frame_format = 'PNG' @@ -65,9 +72,11 @@ INDEX = """<html> <hr/> <form style="display:inline;" method="POST" action="/shutdown" onsubmit="return confirm('This will halt the unit, continue?');"> <input style="display:inline;" type="submit" class="block" value="Shutdown"/> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> </form> <form style="display:inline;" method="POST" action="/restart" onsubmit="return confirm('This will restart the service in %s mode, continue?');"> <input style="display:inline;" type="submit" class="block" value="Restart in %s mode"/> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> </form> </div> @@ -88,141 +97,87 @@ STATUS_PAGE = """<html> </html>""" -class Handler(BaseHTTPRequestHandler): - AllowedOrigin = None # CORS headers are not sent +class RequestHandler: + def __init__(self, app): + self._app = app + self._app.add_url_rule('/', 'index', self.index) + self._app.add_url_rule('/ui', 'ui', self.ui) + self._app.add_url_rule('/shutdown', 'shutdown', self.shutdown, methods=['POST']) + self._app.add_url_rule('/restart', 'restart', self.restart, methods=['POST']) + # plugins + self._app.add_url_rule('/plugins', 'plugins', self.plugins, strict_slashes=False, defaults={'name': None, 'subpath': None}) + self._app.add_url_rule('/plugins/<name>', 'plugins', self.plugins, strict_slashes=False, methods=['GET','POST'], defaults={'subpath': None}) + self._app.add_url_rule('/plugins/<name>/<path:subpath>', 'plugins', self.plugins, methods=['GET','POST']) - # suppress internal logging - def log_message(self, format, *args): - return - def _send_cors_headers(self): - # misc security - self.send_header("X-Frame-Options", "DENY") - self.send_header("X-Content-Type-Options", "nosniff") - self.send_header("X-XSS-Protection", "1; mode=block") - self.send_header("Referrer-Policy", "same-origin") - # cors - if Handler.AllowedOrigin: - self.send_header("Access-Control-Allow-Origin", Handler.AllowedOrigin) - self.send_header('Access-Control-Allow-Credentials', 'true') - self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - self.send_header("Access-Control-Allow-Headers", - "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") - self.send_header("Vary", "Origin") - - # just render some html in a 200 response - def _html(self, html): - self.send_response(200) - self._send_cors_headers() - self.send_header('Content-type', 'text/html') - self.end_headers() - try: - self.wfile.write(bytes(html, "utf8")) - except: - pass - - # serve the main html page - def _index(self): + def index(self): other_mode = 'AUTO' if Agent.INSTANCE.mode == 'manual' else 'MANU' - self._html(INDEX % ( + return render_template_string(INDEX % ( pwnagotchi.name(), other_mode, other_mode, 1000)) + def plugins(self, name, subpath): + if name is None: + # show plugins overview + abort(404) + else: + + # call plugin on_webhook + arguments = request.args + req_method = request.method + + # need to return something here + if name in plugins.loaded and hasattr(plugins.loaded[name], 'on_webhook'): + return render_template_string(plugins.loaded[name].on_webhook(subpath, args=arguments, req_method=req_method)) + + abort(500) + + # serve a message and shuts down the unit - def _shutdown(self): - self._html(STATUS_PAGE % (pwnagotchi.name(), 'Shutting down ...')) + def shutdown(self): pwnagotchi.shutdown() + return render_template_string(STATUS_PAGE % (pwnagotchi.name(), 'Shutting down ...')) # serve a message and restart the unit in the other mode - def _restart(self): + def restart(self): other_mode = 'AUTO' if Agent.INSTANCE.mode == 'manual' else 'MANU' - self._html(STATUS_PAGE % (pwnagotchi.name(), 'Restart in %s mode ...' % other_mode)) pwnagotchi.restart(other_mode) + return render_template_string(STATUS_PAGE % (pwnagotchi.name(), 'Restart in %s mode ...' % other_mode)) # serve the PNG file with the display image - def _image(self): - global frame_lock, frame_path, frame_ctype + def ui(self): + global frame_lock, frame_path with frame_lock: - self.send_response(200) - self._send_cors_headers() - self.send_header('Content-type', frame_ctype) - self.end_headers() - try: - with open(frame_path, 'rb') as fp: - shutil.copyfileobj(fp, self.wfile) - except: - pass - - # check the Origin header vs CORS - def _is_allowed(self): - if not Handler.AllowedOrigin or Handler.AllowedOrigin == '*': - return True - - # TODO: FIX doesn't work with GET requests same-origin - origin = self.headers.get('origin') - if not origin: - logging.warning("request with no Origin header from %s" % self.address_string()) - return False - - if origin != Handler.AllowedOrigin: - logging.warning("request with blocked Origin from %s: %s" % (self.address_string(), origin)) - return False - - return True - - def do_OPTIONS(self): - self.send_response(200) - self._send_cors_headers() - self.end_headers() - - def do_POST(self): - if not self._is_allowed(): - return - elif self.path.startswith('/shutdown'): - self._shutdown() - elif self.path.startswith('/restart'): - self._restart() - else: - self.send_response(404) - - def do_GET(self): - if not self._is_allowed(): - return - elif self.path == '/': - self._index() - elif self.path.startswith('/ui'): - self._image() - elif self.path.startswith('/plugins'): - matches = re.match(r'\/plugins\/([^\/]+)(\/.*)?', self.path) - if matches: - groups = matches.groups() - plugin_name = groups[0] - right_path = groups[1] if len(groups) == 2 else None - plugins.one(plugin_name, 'webhook', self, right_path) - else: - self.send_response(404) + return send_file(frame_path, mimetype='image/png') -class Server(object): +class Server: def __init__(self, config): self._enabled = config['video']['enabled'] self._port = config['video']['port'] self._address = config['video']['address'] - self._httpd = None + self._origin = None if 'origin' in config['video']: - Handler.AllowedOrigin = config['video']['origin'] + self._origin = config['video']['origin'] if self._enabled: _thread.start_new_thread(self._http_serve, ()) def _http_serve(self): if self._address is not None: - self._httpd = HTTPServer((self._address, self._port), Handler) - logging.info("web ui available at http://%s:%d/" % (self._address, self._port)) - self._httpd.serve_forever() + app = Flask(__name__) + app.secret_key = secrets.token_urlsafe(256) + + if self._origin: + CORS(app, resources={r"*": {"origins": self._origin}}) + + CSRFProtect(app) + RequestHandler(app) + + app.run(host=self._address, port=self._port, debug=False) else: logging.info("could not get ip of usb0, video server not starting")