file_manager: update reserved path handling
Allow components to register reserved paths, then perform reserved path validation it upon request. Reserved paths may be registered as read-only or no access. Any request to modify an file/folder that is either reserved or a child of a reserved path is rejected. Signed-off-by: Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
parent
4df6aba6c0
commit
069a655df8
|
@ -693,14 +693,11 @@ class FileRequestHandler(AuthorizedFileHandler):
|
|||
async def delete(self, path: str) -> None:
|
||||
path = self.request.path.lstrip("/").split("/", 2)[-1]
|
||||
path = url_unescape(path, plus=False)
|
||||
file_manager: FileManager
|
||||
file_manager = self.server.lookup_component('file_manager')
|
||||
try:
|
||||
filename = await file_manager.delete_file(path)
|
||||
except self.server.error as e:
|
||||
if e.status_code == 403:
|
||||
raise tornado.web.HTTPError(
|
||||
403, "File is loaded, DELETE not permitted")
|
||||
else:
|
||||
raise tornado.web.HTTPError(e.status_code, str(e))
|
||||
self.finish({'result': filename})
|
||||
|
||||
|
@ -713,6 +710,12 @@ class FileRequestHandler(AuthorizedFileHandler):
|
|||
self.root, absolute_path)
|
||||
if self.absolute_path is None:
|
||||
return
|
||||
file_manager: FileManager
|
||||
file_manager = self.server.lookup_component('file_manager')
|
||||
try:
|
||||
file_manager.check_reserved_path(self.absolute_path, False)
|
||||
except self.server.error as e:
|
||||
raise tornado.web.HTTPError(e.status_code, str(e))
|
||||
|
||||
self.modified = self.get_modified_time()
|
||||
self.set_headers()
|
||||
|
|
|
@ -59,15 +59,16 @@ class FileManager:
|
|||
def __init__(self, config: ConfigHelper) -> None:
|
||||
self.server = config.get_server()
|
||||
self.event_loop = self.server.get_event_loop()
|
||||
self.reserved_paths: Dict[str, pathlib.Path] = {}
|
||||
self.reserved_paths: Dict[str, Tuple[pathlib.Path, bool]] = {}
|
||||
self.full_access_roots: Set[str] = set()
|
||||
self.file_paths: Dict[str, str] = {}
|
||||
self.add_reserved_path("moonraker", MOONRAKER_PATH)
|
||||
db: DBComp = self.server.load_component(config, "database")
|
||||
db_path = db.get_database_path()
|
||||
self.add_reserved_path("database", db_path)
|
||||
app_args = self.server.get_app_args()
|
||||
self.datapath = pathlib.Path(app_args["data_path"])
|
||||
self.add_reserved_path("moonraker", MOONRAKER_PATH, False)
|
||||
db: DBComp = self.server.load_component(config, "database")
|
||||
db_path = db.get_database_path()
|
||||
self.add_reserved_path("database", db_path, False)
|
||||
self.add_reserved_path("certs", self.datapath.joinpath("certs"), False)
|
||||
self.gcode_metadata = MetadataStorage(config, db)
|
||||
self.inotify_handler = INotifyHandler(config, self,
|
||||
self.gcode_metadata)
|
||||
|
@ -144,6 +145,7 @@ class FileManager:
|
|||
# Register path for example configs
|
||||
klipper_path = paths.get('klipper_path', None)
|
||||
if klipper_path is not None:
|
||||
self.reserved_paths.pop("klipper", None)
|
||||
self.add_reserved_path("klipper", klipper_path)
|
||||
example_cfg_path = os.path.join(klipper_path, "config")
|
||||
self.register_directory("config_examples", example_cfg_path)
|
||||
|
@ -205,8 +207,6 @@ class FileManager:
|
|||
return False
|
||||
permissions = os.R_OK
|
||||
if full_access:
|
||||
if not self._check_root_safe(root, path):
|
||||
return False
|
||||
permissions |= os.W_OK
|
||||
self.full_access_roots.add(root)
|
||||
if not os.access(path, permissions):
|
||||
|
@ -229,70 +229,38 @@ class FileManager:
|
|||
"root_update", root, path)
|
||||
return True
|
||||
|
||||
def _paths_overlap(self,
|
||||
path_one: StrOrPath,
|
||||
path_two: StrOrPath
|
||||
def check_reserved_path(
|
||||
self,
|
||||
req_path: StrOrPath,
|
||||
need_write: bool,
|
||||
raise_error: bool = True
|
||||
) -> bool:
|
||||
if isinstance(path_one, str):
|
||||
path_one = pathlib.Path(path_one)
|
||||
path_one = path_one.expanduser().resolve()
|
||||
if isinstance(path_two, str):
|
||||
path_two = pathlib.Path(path_two)
|
||||
path_two = path_two.expanduser().resolve()
|
||||
return (
|
||||
path_one == path_two or
|
||||
path_one in path_two.parents or
|
||||
path_two in path_one.parents
|
||||
)
|
||||
|
||||
def _check_root_safe(self, new_root: str, new_path: StrOrPath) -> bool:
|
||||
# Make sure that registered full access paths
|
||||
# do no overlap one another, nor a reserved path
|
||||
if isinstance(new_path, str):
|
||||
new_path = pathlib.Path(new_path)
|
||||
new_path = new_path.expanduser().resolve()
|
||||
for reg_root, reg_path in self.file_paths.items():
|
||||
exp_reg_path = pathlib.Path(reg_path).expanduser().resolve()
|
||||
if isinstance(req_path, str):
|
||||
req_path = pathlib.Path(req_path)
|
||||
req_path = req_path.expanduser().resolve()
|
||||
for name, (res_path, can_read) in self.reserved_paths.items():
|
||||
if (
|
||||
reg_root not in self.full_access_roots or
|
||||
(reg_root == new_root and new_path == exp_reg_path)
|
||||
(res_path == req_path or res_path in req_path.parents) and
|
||||
(need_write or not can_read)
|
||||
):
|
||||
continue
|
||||
if self._paths_overlap(new_path, exp_reg_path):
|
||||
self.server.add_warning(
|
||||
f"Failed to register '{new_root}': '{new_path}', path "
|
||||
f"overlaps registered root '{reg_root}': '{exp_reg_path}'")
|
||||
return False
|
||||
for res_name, res_path in self.reserved_paths.items():
|
||||
if self._paths_overlap(new_path, res_path):
|
||||
self.server.add_warning(
|
||||
f"Failed to register '{new_root}': '{new_path}', path "
|
||||
f"overlaps reserved path '{res_name}': '{res_path}'")
|
||||
return False
|
||||
if not raise_error:
|
||||
return True
|
||||
raise self.server.error(
|
||||
f"Access to file {req_path.name} forbidden by reserved "
|
||||
f"path '{name}'", 403
|
||||
)
|
||||
return False
|
||||
|
||||
def add_reserved_path(self, name: str, res_path: StrOrPath) -> bool:
|
||||
def add_reserved_path(
|
||||
self, name: str, res_path: StrOrPath, read_access: bool = True
|
||||
) -> bool:
|
||||
if name in self.reserved_paths:
|
||||
return False
|
||||
if isinstance(res_path, str):
|
||||
res_path = pathlib.Path(res_path)
|
||||
res_path = res_path.expanduser().resolve()
|
||||
if (
|
||||
name in self.reserved_paths and
|
||||
res_path == self.reserved_paths[name]
|
||||
):
|
||||
self.reserved_paths[name] = (res_path, read_access)
|
||||
return True
|
||||
self.reserved_paths[name] = res_path
|
||||
check_passed = True
|
||||
for reg_root, reg_path in list(self.file_paths.items()):
|
||||
if reg_root not in self.full_access_roots:
|
||||
continue
|
||||
exp_reg_path = pathlib.Path(reg_path).expanduser().resolve()
|
||||
if self._paths_overlap(res_path, exp_reg_path):
|
||||
self.server.add_warning(
|
||||
f"Full access root '{reg_root}' overlaps reserved path "
|
||||
f"'{name}', removing access")
|
||||
self.file_paths.pop(reg_root, None)
|
||||
check_passed = False
|
||||
return check_passed
|
||||
|
||||
def get_directory(self, root: str = "gcodes") -> str:
|
||||
return self.file_paths.get(root, "")
|
||||
|
@ -367,6 +335,7 @@ class FileManager:
|
|||
dir_info = self._list_directory(dir_path, root, is_extended)
|
||||
return dir_info
|
||||
async with self.write_mutex:
|
||||
self.check_reserved_path(dir_path, True)
|
||||
result = {
|
||||
'item': {'path': directory, 'root': root},
|
||||
'action': "create_dir"}
|
||||
|
@ -460,6 +429,8 @@ class FileManager:
|
|||
if dest_root not in self.full_access_roots:
|
||||
raise self.server.error(
|
||||
f"Destination path is read-only: {dest_root}")
|
||||
self.check_reserved_path(source_path, False)
|
||||
self.check_reserved_path(dest_path, True)
|
||||
async with self.write_mutex:
|
||||
result: Dict[str, Any] = {'item': {'root': dest_root}}
|
||||
if not os.path.exists(source_path):
|
||||
|
@ -540,16 +511,19 @@ class FileManager:
|
|||
}
|
||||
return flist
|
||||
|
||||
def get_path_info(self, path: str, root: str) -> Dict[str, Any]:
|
||||
fstat = os.stat(path)
|
||||
real_path = os.path.realpath(path)
|
||||
def get_path_info(self, path: StrOrPath, root: str) -> Dict[str, Any]:
|
||||
if isinstance(path, str):
|
||||
path = pathlib.Path(path)
|
||||
real_path = path.resolve()
|
||||
fstat = path.stat()
|
||||
permissions = "rw"
|
||||
if (
|
||||
(os.path.islink(path) and os.path.isfile(real_path)) or
|
||||
not os.access(real_path, os.R_OK | os.W_OK) or
|
||||
root not in self.full_access_roots
|
||||
root not in self.full_access_roots or
|
||||
(path.is_symlink() and path.is_file())
|
||||
):
|
||||
permissions = "r"
|
||||
if self.check_reserved_path(real_path, permissions == "rw", False):
|
||||
permissions = ""
|
||||
return {
|
||||
'modified': fstat.st_mtime,
|
||||
'size': fstat.st_size,
|
||||
|
@ -569,6 +543,7 @@ class FileManager:
|
|||
async with self.write_mutex:
|
||||
try:
|
||||
upload_info = self._parse_upload_args(form_args)
|
||||
self.check_reserved_path(upload_info["dest_path"], True)
|
||||
root = upload_info['root']
|
||||
if root == "gcodes" and upload_info['ext'] in VALID_GCODE_EXTS:
|
||||
result = await self._finish_gcode_upload(upload_info)
|
||||
|
@ -820,6 +795,7 @@ class FileManager:
|
|||
async def delete_file(self, path: str) -> Dict[str, Any]:
|
||||
async with self.write_mutex:
|
||||
root, full_path = self._convert_request_path(path)
|
||||
self.check_reserved_path(full_path, True)
|
||||
filename = self.get_relative_path(root, full_path)
|
||||
if root not in self.full_access_roots:
|
||||
raise self.server.error(
|
||||
|
|
|
@ -16,6 +16,7 @@ from typing import (
|
|||
)
|
||||
if TYPE_CHECKING:
|
||||
from confighelper import ConfigHelper
|
||||
from .file_manager.file_manager import FileManager
|
||||
|
||||
class Secrets:
|
||||
def __init__(self, config: ConfigHelper) -> None:
|
||||
|
@ -30,6 +31,8 @@ class Secrets:
|
|||
fpath = pathlib.Path(path).expanduser().resolve()
|
||||
self.type = "invalid"
|
||||
self.values: Dict[str, Any] = {}
|
||||
fm: FileManager = server.lookup_component("file_manager")
|
||||
fm.add_reserved_path("secrets", fpath, False)
|
||||
if fpath.is_file():
|
||||
self.secrets_file = fpath
|
||||
data = self.secrets_file.read_text()
|
||||
|
|
|
@ -25,6 +25,7 @@ if TYPE_CHECKING:
|
|||
from confighelper import ConfigHelper
|
||||
from .update_manager import CommandHelper
|
||||
from ..machine import Machine
|
||||
from ..file_manager.file_manager import FileManager
|
||||
|
||||
SUPPORTED_CHANNELS = {
|
||||
"zip": ["stable", "beta"],
|
||||
|
@ -60,6 +61,12 @@ class AppDeploy(BaseDeploy):
|
|||
self.type = "zip"
|
||||
self.path = pathlib.Path(
|
||||
config.get('path')).expanduser().resolve()
|
||||
if (
|
||||
self.name not in ["moonraker", "klipper"]
|
||||
and not self.path.joinpath(".writeable").is_file()
|
||||
):
|
||||
fm: FileManager = self.server.lookup_component("file_manager")
|
||||
fm.add_reserved_path(f"update_manager {self.name}", self.path)
|
||||
executable = config.get('env', None)
|
||||
if self.channel not in SUPPORTED_CHANNELS[self.type]:
|
||||
raise config.error(
|
||||
|
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||
import os
|
||||
import sys
|
||||
import copy
|
||||
from utils import MOONRAKER_PATH
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Dict
|
||||
|
@ -17,8 +18,6 @@ if TYPE_CHECKING:
|
|||
from confighelper import ConfigHelper
|
||||
from components.database import MoonrakerDatabase
|
||||
|
||||
MOONRAKER_PATH = os.path.normpath(os.path.join(
|
||||
os.path.dirname(__file__), "../../.."))
|
||||
KLIPPER_DEFAULT_PATH = os.path.expanduser("~/klipper")
|
||||
KLIPPER_DEFAULT_EXEC = os.path.expanduser("~/klippy-env/bin/python")
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ class BaseDeploy:
|
|||
cfg_hash: Optional[str] = None
|
||||
) -> None:
|
||||
if name is None:
|
||||
name = config.get_name().split()[-1]
|
||||
name = config.get_name().split(maxsplit=1)[-1]
|
||||
self.name = name
|
||||
if prefix:
|
||||
prefix = f"{prefix} {self.name}: "
|
||||
|
|
|
@ -46,6 +46,7 @@ if TYPE_CHECKING:
|
|||
from components.dbus_manager import DbusManager
|
||||
from components.machine import Machine
|
||||
from components.http_client import HttpClient
|
||||
from components.file_manager.file_manager import FileManager
|
||||
from eventloop import FlexTimer
|
||||
from dbus_next import Variant
|
||||
from dbus_next.aio import ProxyInterface
|
||||
|
@ -1133,6 +1134,8 @@ class WebClientDeploy(BaseDeploy):
|
|||
self.repo = config.get('repo').strip().strip("/")
|
||||
self.owner = self.repo.split("/", 1)[0]
|
||||
self.path = pathlib.Path(config.get("path")).expanduser().resolve()
|
||||
fm: FileManager = self.server.lookup_component("file_manager")
|
||||
fm.add_reserved_path(f"update_manager {self.name}", self.path)
|
||||
self.type = config.get('type')
|
||||
def_channel = "stable"
|
||||
if self.type == "web_beta":
|
||||
|
|
Loading…
Reference in New Issue