diff --git a/src/openflexure_microscope_server/ui.py b/src/openflexure_microscope_server/ui.py index 1ed23127..b6692785 100644 --- a/src/openflexure_microscope_server/ui.py +++ b/src/openflexure_microscope_server/ui.py @@ -3,6 +3,7 @@ from html import escape from html.parser import HTMLParser from typing import Annotated, Any, Literal, Optional +from urllib.parse import urlparse from pydantic import BaseModel, BeforeValidator, RootModel @@ -23,6 +24,25 @@ ALLOWED_TAGS = { "a", } +ALLOWED_SCHEMES = {"http", "https"} + + +def strip_control_chars(input_string: str) -> str: + """Remove unprintable characters.""" + return "".join(char for char in input_string if char.isprintable()) + + +def sanitize_http_url(url: str) -> Optional[str]: + """Santitise any url only allowing http and https without queries.""" + url = strip_control_chars(url) + parsed = urlparse(url) + if parsed.scheme not in ALLOWED_SCHEMES: + return None + if not parsed.netloc: + return None + fragment_str = "#" + parsed.fragment if parsed.fragment else "" + return f"{parsed.scheme}://{parsed.netloc}{fragment_str}" + class SafeHTMLParser(HTMLParser): """An HTMLParser that only allows a very limited subset of HTML.""" @@ -53,7 +73,8 @@ class SafeHTMLParser(HTMLParser): href = None for attr, value in attrs: if attr == "href" and value is not None: - href = value.strip() + # If it is not a httplink this will stay as None + href = sanitize_http_url(value.strip()) if href: safe_href = escape(href, quote=True) @@ -92,7 +113,7 @@ class SafeHTMLParser(HTMLParser): self.output += f"{name};" -def sanitize_html(html: str) -> str: +def sanitise_html(html: str) -> str: """Santitise HTML to only have a small list of allowed tags. Tags allowed without attrs: ``
, , , Hello Hello,
,
,
,
,
,``
@@ -113,7 +134,7 @@ def sanitize_html(html: str) -> str:
return parser.output
-HtmlFragment = Annotated[str, BeforeValidator(sanitize_html)]
+HtmlFragment = Annotated[str, BeforeValidator(sanitise_html)]
class HTMLBlock(BaseModel):
diff --git a/tests/shared_utils/md_test_cases.py b/tests/shared_utils/md_test_cases.py
new file mode 100644
index 00000000..9a3e3b37
--- /dev/null
+++ b/tests/shared_utils/md_test_cases.py
@@ -0,0 +1,109 @@
+"""Read multi-line test cases from markdown files.
+
+When checking if text coercion is working multiline Python can be very difficult
+to read and maintain. This file allows the input and expected results to be written
+into markdown codeblocks.
+
+Each test is a second level heading.
+"""
+
+import re
+
+# Level 2 markdown heading. Group 1 is the text
+CASE_REGEX = re.compile(r"^##\s(.*)", re.MULTILINE)
+# Level 3 markdown heading. Group 1 is the text
+LEVEL3_REGEX = re.compile(r"^###\s(.*)", re.MULTILINE)
+# Markdown code block.
+# Group 1 is the language specification after the back ticks
+# Group 2 is the code
+CODE_REGEX = re.compile(
+ r"^```([a-zA-Z]+)?\s?\n(?:(.*?)\n)?```", re.MULTILINE | re.DOTALL
+)
+
+
+class MdTestCase:
+ """A class to represent a test case read from a markdown file.
+
+ Useful attributes:
+
+ * title - The title of the test case
+ * input_code - the input code for the test case
+ * result_code - the expected result code for the test case
+ """
+
+ def __init__(self, title: str, case_text: str, block_type: str) -> None:
+ """Initialise the test case.
+
+ :param title: The title of the test case.
+ :param case_text: The full text of the test case.
+ :param block_type: The type of code block expected (the label after the triple
+ backticks).
+ """
+ self.title = title
+ self.block_type = block_type
+ self.input_code, self.result_code = self._parse(case_text)
+
+ def __repr__(self):
+ """Return the string representation of the test case."""
+ return f"
+```
+### Result
+```html
+```
+
+## Test cookie grabber script is escaped
+The script should become escaped and have no tags
+### Input
+```html
+
+```
+### Result
+```html
+var adr = '../evil.php?cakemonster=' + escape(document.cookie);
+```
+
+## Test with non-http links
+Remove the javascript from the links.
+### Input
+```html
+link
+encoded
+Email
+Relative
+```
+### Result
+```html
+link
+encoded
+Email
+Relative
+```
+
+## Test http links become external
+Remove the javascript from the links.
+### Input
+```html
+OpenFlexure
+```
+### Result
+```html
+OpenFlexure
+```
\ No newline at end of file
diff --git a/tests/unit_tests/test_ui.py b/tests/unit_tests/test_ui.py
new file mode 100644
index 00000000..1233458c
--- /dev/null
+++ b/tests/unit_tests/test_ui.py
@@ -0,0 +1,24 @@
+"""Testing the server specified user interface."""
+
+import os
+
+import pytest
+
+from openflexure_microscope_server import ui
+
+from ..shared_utils.md_test_cases import find_test_cases
+
+THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+HTML_SANITISATION_TEST_FILE = os.path.join(
+ THIS_DIR, "data", "html_sanitisation_test_cases.md"
+)
+HTML_SANITISATION_TEST_CASES = find_test_cases(HTML_SANITISATION_TEST_FILE, "html")
+
+
+@pytest.mark.parametrize("test_case", HTML_SANITISATION_TEST_CASES)
+def test_html_block_sanitises(test_case):
+ """Check each case in the test case file sanitises as expected."""
+ sanitised = ui.sanitise_html(test_case.input_code).strip()
+ assert sanitised == test_case.result_code, (
+ f"Test case: '{test_case.title}': Input code doesn't sanitise as expected."
+ )