Add CSRF protection to destructive endpoints
For management endpoints that change server state (restart, ban, etc), add a referer header check to safeguard against both CSRF and accidental browser history completion. Closes #1
This commit is contained in:
parent
d1767bc1b4
commit
bc79be8a96
2 changed files with 53 additions and 8 deletions
|
@ -1,5 +1,6 @@
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import cherrypy
|
import cherrypy
|
||||||
from mako.template import Template
|
from mako.template import Template
|
||||||
|
@ -8,37 +9,65 @@ import config
|
||||||
import ovenapi
|
import ovenapi
|
||||||
|
|
||||||
|
|
||||||
@cherrypy.tools.register("on_end_request")
|
|
||||||
def restart_server() -> None:
|
|
||||||
subprocess.call(["sudo", "/usr/bin/systemctl", "restart", "ovenmediaengine"])
|
|
||||||
|
|
||||||
|
|
||||||
class Management:
|
class Management:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.page_template = Path("template/management.mako").read_text(encoding="utf-8")
|
self.page_template = Path("template/management.mako").read_text(encoding="utf-8")
|
||||||
self.redirect_template = Path("template/message.mako").read_text(encoding="utf-8")
|
self.redirect_template = Path("template/message.mako").read_text(encoding="utf-8")
|
||||||
self.api = ovenapi.OvenAPI(config.API_USER, config.API_PASS)
|
self.api = ovenapi.OvenAPI(config.API_USER, config.API_PASS)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __verify_same_domain() -> bool:
|
||||||
|
"""
|
||||||
|
Verify that the requested domain and referer domain match.
|
||||||
|
|
||||||
|
This is mainly intended to be used as a guard for "destructive" endpoints such as
|
||||||
|
/management/restart. This safeguards against cross-site requests as well as accidental
|
||||||
|
history completions such as intents to access /management but one's browser helpfully
|
||||||
|
populates /management/restart.
|
||||||
|
"""
|
||||||
|
referer = cherrypy.request.headers.get("Referer", "").lower()
|
||||||
|
|
||||||
|
# For comparing request and referer domains we drop the port
|
||||||
|
referer_domain = urlparse(referer).netloc.split(":")[0]
|
||||||
|
request_domain = urlparse(cherrypy.request.base).netloc.split(":")[0]
|
||||||
|
|
||||||
|
return referer_domain == request_domain
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __restart_server() -> None:
|
||||||
|
subprocess.call(["sudo", "/usr/bin/systemctl", "restart", "ovenmediaengine"])
|
||||||
|
|
||||||
def __message_and_redirect(self, message: str) -> bytes | str:
|
def __message_and_redirect(self, message: str) -> bytes | str:
|
||||||
return Template(self.redirect_template).render(message=message)
|
return Template(self.redirect_template).render(message=message)
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@cherrypy.tools.restart_server()
|
|
||||||
def restart(self) -> bytes | str:
|
def restart(self) -> bytes | str:
|
||||||
|
if not self.__verify_same_domain():
|
||||||
|
cherrypy.response.status = 403
|
||||||
|
return "Cross-site request detected. Please go back to /management and try again"
|
||||||
|
|
||||||
# Blank our stream list because we're about to DC everyone
|
# Blank our stream list because we're about to DC everyone
|
||||||
config.LAST_STREAM_LIST = []
|
config.LAST_STREAM_LIST = []
|
||||||
|
|
||||||
|
self.__restart_server()
|
||||||
|
|
||||||
# Compile and dispatch our response
|
# Compile and dispatch our response
|
||||||
return self.__message_and_redirect("Restart command dispatched")
|
return self.__message_and_redirect("Server restarted")
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def disconnect(self, target):
|
def disconnect(self, target):
|
||||||
|
if not self.__verify_same_domain():
|
||||||
|
cherrypy.response.status = 403
|
||||||
|
return "Cross-site request detected. Please go back to /management and try again"
|
||||||
vhost, app, stream = target.split(":")
|
vhost, app, stream = target.split(":")
|
||||||
self.api.disconnect_key(vhost, app, stream)
|
self.api.disconnect_key(vhost, app, stream)
|
||||||
return self.__message_and_redirect(f"Disconnected {target}")
|
return self.__message_and_redirect(f"Disconnected {target}")
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def ban(self, target):
|
def ban(self, target):
|
||||||
|
if not self.__verify_same_domain():
|
||||||
|
cherrypy.response.status = 403
|
||||||
|
return "Cross-site request detected. Please go back to /management and try again"
|
||||||
vhost, app, stream = target.split(":")
|
vhost, app, stream = target.split(":")
|
||||||
ip = self.api.get_stream_ip(vhost, app, stream)
|
ip = self.api.get_stream_ip(vhost, app, stream)
|
||||||
if ip:
|
if ip:
|
||||||
|
@ -49,6 +78,9 @@ class Management:
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def unban(self, target):
|
def unban(self, target):
|
||||||
|
if not self.__verify_same_domain():
|
||||||
|
cherrypy.response.status = 403
|
||||||
|
return "Cross-site request detected. Please go back to /management and try again"
|
||||||
if target in config.BLOCKED_IPS:
|
if target in config.BLOCKED_IPS:
|
||||||
config.BLOCKED_IPS.remove(target)
|
config.BLOCKED_IPS.remove(target)
|
||||||
return self.__message_and_redirect(f"Unbanned {target}")
|
return self.__message_and_redirect(f"Unbanned {target}")
|
||||||
|
@ -56,12 +88,18 @@ class Management:
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def disable(self, target):
|
def disable(self, target):
|
||||||
|
if not self.__verify_same_domain():
|
||||||
|
cherrypy.response.status = 403
|
||||||
|
return "Cross-site request detected. Please go back to /management and try again"
|
||||||
config.DISABLED_KEYS.append(target)
|
config.DISABLED_KEYS.append(target)
|
||||||
self.disconnect(target)
|
self.disconnect(target)
|
||||||
return self.__message_and_redirect(f"Disabled key {target}")
|
return self.__message_and_redirect(f"Disabled key {target}")
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def enable(self, target):
|
def enable(self, target):
|
||||||
|
if not self.__verify_same_domain():
|
||||||
|
cherrypy.response.status = 403
|
||||||
|
return "Cross-site request detected. Please go back to /management and try again"
|
||||||
if target in config.DISABLED_KEYS:
|
if target in config.DISABLED_KEYS:
|
||||||
config.DISABLED_KEYS.remove(target)
|
config.DISABLED_KEYS.remove(target)
|
||||||
return self.__message_and_redirect(f"Re-enabled {target}")
|
return self.__message_and_redirect(f"Re-enabled {target}")
|
||||||
|
@ -69,12 +107,18 @@ class Management:
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
if not self.__verify_same_domain():
|
||||||
|
cherrypy.response.status = 403
|
||||||
|
return "Cross-site request detected. Please go back to /management and try again"
|
||||||
config.DISABLED = True
|
config.DISABLED = True
|
||||||
self.api.disconnect_all()
|
self.api.disconnect_all()
|
||||||
return self.__message_and_redirect("Server disabled")
|
return self.__message_and_redirect("Server disabled")
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def start(self):
|
def start(self):
|
||||||
|
if not self.__verify_same_domain():
|
||||||
|
cherrypy.response.status = 403
|
||||||
|
return "Cross-site request detected. Please go back to /management and try again"
|
||||||
config.DISABLED = False
|
config.DISABLED = False
|
||||||
return self.__message_and_redirect("Server re-enabled")
|
return self.__message_and_redirect("Server re-enabled")
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import cherrypy
|
import cherrypy
|
||||||
from mako.template import Template
|
from mako.template import Template
|
||||||
|
@ -39,7 +40,7 @@ class Viewer:
|
||||||
return "App not found"
|
return "App not found"
|
||||||
|
|
||||||
# Get domain for templates
|
# Get domain for templates
|
||||||
domain = cherrypy.request.base.split("/")[-1].split(":")[0]
|
domain = urlparse(cherrypy.request.base).netloc
|
||||||
|
|
||||||
# Any subpath is presumed to be a single player interface for app/stream
|
# Any subpath is presumed to be a single player interface for app/stream
|
||||||
if "page" in params:
|
if "page" in params:
|
||||||
|
|
Loading…
Add table
Reference in a new issue