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,15 +693,12 @@ class FileRequestHandler(AuthorizedFileHandler):
|
||||||
async def delete(self, path: str) -> None:
|
async def delete(self, path: str) -> None:
|
||||||
path = self.request.path.lstrip("/").split("/", 2)[-1]
|
path = self.request.path.lstrip("/").split("/", 2)[-1]
|
||||||
path = url_unescape(path, plus=False)
|
path = url_unescape(path, plus=False)
|
||||||
|
file_manager: FileManager
|
||||||
file_manager = self.server.lookup_component('file_manager')
|
file_manager = self.server.lookup_component('file_manager')
|
||||||
try:
|
try:
|
||||||
filename = await file_manager.delete_file(path)
|
filename = await file_manager.delete_file(path)
|
||||||
except self.server.error as e:
|
except self.server.error as e:
|
||||||
if e.status_code == 403:
|
raise tornado.web.HTTPError(e.status_code, str(e))
|
||||||
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})
|
self.finish({'result': filename})
|
||||||
|
|
||||||
async def get(self, path: str, include_body: bool = True) -> None:
|
async def get(self, path: str, include_body: bool = True) -> None:
|
||||||
|
@ -713,6 +710,12 @@ class FileRequestHandler(AuthorizedFileHandler):
|
||||||
self.root, absolute_path)
|
self.root, absolute_path)
|
||||||
if self.absolute_path is None:
|
if self.absolute_path is None:
|
||||||
return
|
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.modified = self.get_modified_time()
|
||||||
self.set_headers()
|
self.set_headers()
|
||||||
|
|
|
@ -59,15 +59,16 @@ class FileManager:
|
||||||
def __init__(self, config: ConfigHelper) -> None:
|
def __init__(self, config: ConfigHelper) -> None:
|
||||||
self.server = config.get_server()
|
self.server = config.get_server()
|
||||||
self.event_loop = self.server.get_event_loop()
|
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.full_access_roots: Set[str] = set()
|
||||||
self.file_paths: Dict[str, str] = {}
|
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()
|
app_args = self.server.get_app_args()
|
||||||
self.datapath = pathlib.Path(app_args["data_path"])
|
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.gcode_metadata = MetadataStorage(config, db)
|
||||||
self.inotify_handler = INotifyHandler(config, self,
|
self.inotify_handler = INotifyHandler(config, self,
|
||||||
self.gcode_metadata)
|
self.gcode_metadata)
|
||||||
|
@ -144,6 +145,7 @@ class FileManager:
|
||||||
# Register path for example configs
|
# Register path for example configs
|
||||||
klipper_path = paths.get('klipper_path', None)
|
klipper_path = paths.get('klipper_path', None)
|
||||||
if klipper_path is not None:
|
if klipper_path is not None:
|
||||||
|
self.reserved_paths.pop("klipper", None)
|
||||||
self.add_reserved_path("klipper", klipper_path)
|
self.add_reserved_path("klipper", klipper_path)
|
||||||
example_cfg_path = os.path.join(klipper_path, "config")
|
example_cfg_path = os.path.join(klipper_path, "config")
|
||||||
self.register_directory("config_examples", example_cfg_path)
|
self.register_directory("config_examples", example_cfg_path)
|
||||||
|
@ -205,8 +207,6 @@ class FileManager:
|
||||||
return False
|
return False
|
||||||
permissions = os.R_OK
|
permissions = os.R_OK
|
||||||
if full_access:
|
if full_access:
|
||||||
if not self._check_root_safe(root, path):
|
|
||||||
return False
|
|
||||||
permissions |= os.W_OK
|
permissions |= os.W_OK
|
||||||
self.full_access_roots.add(root)
|
self.full_access_roots.add(root)
|
||||||
if not os.access(path, permissions):
|
if not os.access(path, permissions):
|
||||||
|
@ -229,70 +229,38 @@ class FileManager:
|
||||||
"root_update", root, path)
|
"root_update", root, path)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _paths_overlap(self,
|
def check_reserved_path(
|
||||||
path_one: StrOrPath,
|
self,
|
||||||
path_two: StrOrPath
|
req_path: StrOrPath,
|
||||||
) -> bool:
|
need_write: bool,
|
||||||
if isinstance(path_one, str):
|
raise_error: bool = True
|
||||||
path_one = pathlib.Path(path_one)
|
) -> bool:
|
||||||
path_one = path_one.expanduser().resolve()
|
if isinstance(req_path, str):
|
||||||
if isinstance(path_two, str):
|
req_path = pathlib.Path(req_path)
|
||||||
path_two = pathlib.Path(path_two)
|
req_path = req_path.expanduser().resolve()
|
||||||
path_two = path_two.expanduser().resolve()
|
for name, (res_path, can_read) in self.reserved_paths.items():
|
||||||
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 (
|
if (
|
||||||
reg_root not in self.full_access_roots or
|
(res_path == req_path or res_path in req_path.parents) and
|
||||||
(reg_root == new_root and new_path == exp_reg_path)
|
(need_write or not can_read)
|
||||||
):
|
):
|
||||||
continue
|
if not raise_error:
|
||||||
if self._paths_overlap(new_path, exp_reg_path):
|
return True
|
||||||
self.server.add_warning(
|
raise self.server.error(
|
||||||
f"Failed to register '{new_root}': '{new_path}', path "
|
f"Access to file {req_path.name} forbidden by reserved "
|
||||||
f"overlaps registered root '{reg_root}': '{exp_reg_path}'")
|
f"path '{name}'", 403
|
||||||
return False
|
)
|
||||||
for res_name, res_path in self.reserved_paths.items():
|
return False
|
||||||
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
|
|
||||||
return True
|
|
||||||
|
|
||||||
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):
|
if isinstance(res_path, str):
|
||||||
res_path = pathlib.Path(res_path)
|
res_path = pathlib.Path(res_path)
|
||||||
res_path = res_path.expanduser().resolve()
|
res_path = res_path.expanduser().resolve()
|
||||||
if (
|
self.reserved_paths[name] = (res_path, read_access)
|
||||||
name in self.reserved_paths and
|
return True
|
||||||
res_path == self.reserved_paths[name]
|
|
||||||
):
|
|
||||||
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:
|
def get_directory(self, root: str = "gcodes") -> str:
|
||||||
return self.file_paths.get(root, "")
|
return self.file_paths.get(root, "")
|
||||||
|
@ -367,6 +335,7 @@ class FileManager:
|
||||||
dir_info = self._list_directory(dir_path, root, is_extended)
|
dir_info = self._list_directory(dir_path, root, is_extended)
|
||||||
return dir_info
|
return dir_info
|
||||||
async with self.write_mutex:
|
async with self.write_mutex:
|
||||||
|
self.check_reserved_path(dir_path, True)
|
||||||
result = {
|
result = {
|
||||||
'item': {'path': directory, 'root': root},
|
'item': {'path': directory, 'root': root},
|
||||||
'action': "create_dir"}
|
'action': "create_dir"}
|
||||||
|
@ -460,6 +429,8 @@ class FileManager:
|
||||||
if dest_root not in self.full_access_roots:
|
if dest_root not in self.full_access_roots:
|
||||||
raise self.server.error(
|
raise self.server.error(
|
||||||
f"Destination path is read-only: {dest_root}")
|
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:
|
async with self.write_mutex:
|
||||||
result: Dict[str, Any] = {'item': {'root': dest_root}}
|
result: Dict[str, Any] = {'item': {'root': dest_root}}
|
||||||
if not os.path.exists(source_path):
|
if not os.path.exists(source_path):
|
||||||
|
@ -540,16 +511,19 @@ class FileManager:
|
||||||
}
|
}
|
||||||
return flist
|
return flist
|
||||||
|
|
||||||
def get_path_info(self, path: str, root: str) -> Dict[str, Any]:
|
def get_path_info(self, path: StrOrPath, root: str) -> Dict[str, Any]:
|
||||||
fstat = os.stat(path)
|
if isinstance(path, str):
|
||||||
real_path = os.path.realpath(path)
|
path = pathlib.Path(path)
|
||||||
|
real_path = path.resolve()
|
||||||
|
fstat = path.stat()
|
||||||
permissions = "rw"
|
permissions = "rw"
|
||||||
if (
|
if (
|
||||||
(os.path.islink(path) and os.path.isfile(real_path)) or
|
root not in self.full_access_roots or
|
||||||
not os.access(real_path, os.R_OK | os.W_OK) or
|
(path.is_symlink() and path.is_file())
|
||||||
root not in self.full_access_roots
|
|
||||||
):
|
):
|
||||||
permissions = "r"
|
permissions = "r"
|
||||||
|
if self.check_reserved_path(real_path, permissions == "rw", False):
|
||||||
|
permissions = ""
|
||||||
return {
|
return {
|
||||||
'modified': fstat.st_mtime,
|
'modified': fstat.st_mtime,
|
||||||
'size': fstat.st_size,
|
'size': fstat.st_size,
|
||||||
|
@ -569,6 +543,7 @@ class FileManager:
|
||||||
async with self.write_mutex:
|
async with self.write_mutex:
|
||||||
try:
|
try:
|
||||||
upload_info = self._parse_upload_args(form_args)
|
upload_info = self._parse_upload_args(form_args)
|
||||||
|
self.check_reserved_path(upload_info["dest_path"], True)
|
||||||
root = upload_info['root']
|
root = upload_info['root']
|
||||||
if root == "gcodes" and upload_info['ext'] in VALID_GCODE_EXTS:
|
if root == "gcodes" and upload_info['ext'] in VALID_GCODE_EXTS:
|
||||||
result = await self._finish_gcode_upload(upload_info)
|
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 def delete_file(self, path: str) -> Dict[str, Any]:
|
||||||
async with self.write_mutex:
|
async with self.write_mutex:
|
||||||
root, full_path = self._convert_request_path(path)
|
root, full_path = self._convert_request_path(path)
|
||||||
|
self.check_reserved_path(full_path, True)
|
||||||
filename = self.get_relative_path(root, full_path)
|
filename = self.get_relative_path(root, full_path)
|
||||||
if root not in self.full_access_roots:
|
if root not in self.full_access_roots:
|
||||||
raise self.server.error(
|
raise self.server.error(
|
||||||
|
|
|
@ -16,6 +16,7 @@ from typing import (
|
||||||
)
|
)
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from confighelper import ConfigHelper
|
from confighelper import ConfigHelper
|
||||||
|
from .file_manager.file_manager import FileManager
|
||||||
|
|
||||||
class Secrets:
|
class Secrets:
|
||||||
def __init__(self, config: ConfigHelper) -> None:
|
def __init__(self, config: ConfigHelper) -> None:
|
||||||
|
@ -30,6 +31,8 @@ class Secrets:
|
||||||
fpath = pathlib.Path(path).expanduser().resolve()
|
fpath = pathlib.Path(path).expanduser().resolve()
|
||||||
self.type = "invalid"
|
self.type = "invalid"
|
||||||
self.values: Dict[str, Any] = {}
|
self.values: Dict[str, Any] = {}
|
||||||
|
fm: FileManager = server.lookup_component("file_manager")
|
||||||
|
fm.add_reserved_path("secrets", fpath, False)
|
||||||
if fpath.is_file():
|
if fpath.is_file():
|
||||||
self.secrets_file = fpath
|
self.secrets_file = fpath
|
||||||
data = self.secrets_file.read_text()
|
data = self.secrets_file.read_text()
|
||||||
|
|
|
@ -25,6 +25,7 @@ if TYPE_CHECKING:
|
||||||
from confighelper import ConfigHelper
|
from confighelper import ConfigHelper
|
||||||
from .update_manager import CommandHelper
|
from .update_manager import CommandHelper
|
||||||
from ..machine import Machine
|
from ..machine import Machine
|
||||||
|
from ..file_manager.file_manager import FileManager
|
||||||
|
|
||||||
SUPPORTED_CHANNELS = {
|
SUPPORTED_CHANNELS = {
|
||||||
"zip": ["stable", "beta"],
|
"zip": ["stable", "beta"],
|
||||||
|
@ -60,6 +61,12 @@ class AppDeploy(BaseDeploy):
|
||||||
self.type = "zip"
|
self.type = "zip"
|
||||||
self.path = pathlib.Path(
|
self.path = pathlib.Path(
|
||||||
config.get('path')).expanduser().resolve()
|
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)
|
executable = config.get('env', None)
|
||||||
if self.channel not in SUPPORTED_CHANNELS[self.type]:
|
if self.channel not in SUPPORTED_CHANNELS[self.type]:
|
||||||
raise config.error(
|
raise config.error(
|
||||||
|
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import copy
|
import copy
|
||||||
|
from utils import MOONRAKER_PATH
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Dict
|
Dict
|
||||||
|
@ -17,8 +18,6 @@ if TYPE_CHECKING:
|
||||||
from confighelper import ConfigHelper
|
from confighelper import ConfigHelper
|
||||||
from components.database import MoonrakerDatabase
|
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_PATH = os.path.expanduser("~/klipper")
|
||||||
KLIPPER_DEFAULT_EXEC = os.path.expanduser("~/klippy-env/bin/python")
|
KLIPPER_DEFAULT_EXEC = os.path.expanduser("~/klippy-env/bin/python")
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ class BaseDeploy:
|
||||||
cfg_hash: Optional[str] = None
|
cfg_hash: Optional[str] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
if name is None:
|
if name is None:
|
||||||
name = config.get_name().split()[-1]
|
name = config.get_name().split(maxsplit=1)[-1]
|
||||||
self.name = name
|
self.name = name
|
||||||
if prefix:
|
if prefix:
|
||||||
prefix = f"{prefix} {self.name}: "
|
prefix = f"{prefix} {self.name}: "
|
||||||
|
|
|
@ -46,6 +46,7 @@ if TYPE_CHECKING:
|
||||||
from components.dbus_manager import DbusManager
|
from components.dbus_manager import DbusManager
|
||||||
from components.machine import Machine
|
from components.machine import Machine
|
||||||
from components.http_client import HttpClient
|
from components.http_client import HttpClient
|
||||||
|
from components.file_manager.file_manager import FileManager
|
||||||
from eventloop import FlexTimer
|
from eventloop import FlexTimer
|
||||||
from dbus_next import Variant
|
from dbus_next import Variant
|
||||||
from dbus_next.aio import ProxyInterface
|
from dbus_next.aio import ProxyInterface
|
||||||
|
@ -1133,6 +1134,8 @@ class WebClientDeploy(BaseDeploy):
|
||||||
self.repo = config.get('repo').strip().strip("/")
|
self.repo = config.get('repo').strip().strip("/")
|
||||||
self.owner = self.repo.split("/", 1)[0]
|
self.owner = self.repo.split("/", 1)[0]
|
||||||
self.path = pathlib.Path(config.get("path")).expanduser().resolve()
|
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')
|
self.type = config.get('type')
|
||||||
def_channel = "stable"
|
def_channel = "stable"
|
||||||
if self.type == "web_beta":
|
if self.type == "web_beta":
|
||||||
|
|
Loading…
Reference in New Issue