diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/skyhook/handlers.py b/skyhook/handlers.py new file mode 100644 index 0000000..697b25f --- /dev/null +++ b/skyhook/handlers.py @@ -0,0 +1,61 @@ +import inspect +import types +from typing import Callable, Iterator, Optional + + +def skyhook_handler(fn: Optional[Callable] = None, *, html: bool = False) -> Callable: + """ + Decorator to mark a function as a Skyhook handler. + + Works in three forms: + @skyhook_handler # no parentheses + @skyhook_handler() # parentheses, no args + @skyhook_handler(html=True) # with html kwarg + + Sets __skyhook_handler__ = True on the function, marking it as dispatchable. + When html=True, also sets __skyhook_html_handler__ = True, signaling that + the handler returns HTML and should have origin-allowlist checks applied. + + Args: + fn: The function being decorated (None if called with parentheses) + html: If True, mark this as an HTML handler + + Returns: + The decorated function or a decorator function + """ + if fn is not None and not callable(fn): + raise TypeError( + "@skyhook_handler: unexpected positional argument. " + "Use @skyhook_handler() or @skyhook_handler(html=True)." + ) + + def decorator(func: Callable) -> Callable: + func.__skyhook_handler__ = True + func.__skyhook_html_handler__ = html + return func + + # Case 1: @skyhook_handler (no parentheses, fn is the decorated function) + if fn is not None: + return decorator(fn) + + # Case 2 & 3: @skyhook_handler() or @skyhook_handler(html=True) + return decorator + + +def iter_handlers(module: types.ModuleType) -> Iterator[Callable]: + """ + Yield all callables in a module that have __skyhook_handler__ = True. + + Skips handlers that were imported into the module rather than defined there. + + Args: + module: A Python module to scan for handlers + + Yields: + Callable objects marked as Skyhook handlers defined in this module + """ + for name in dir(module): + obj = getattr(module, name) + if callable(obj) and getattr(obj, "__skyhook_handler__", False): + if inspect.getmodule(obj) is module: + yield obj diff --git a/skyhook/responses.py b/skyhook/responses.py new file mode 100644 index 0000000..a5ebd87 --- /dev/null +++ b/skyhook/responses.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class HtmlResponse: + """ + Sentinel class for handler functions that return HTML content. + + When a handler returns an HtmlResponse, the server will emit the body + verbatim as HTML instead of encoding it as JSON. + """ + body: str + status: int = 200 + content_type: str = "text/html; charset=utf-8" + + +@dataclass(frozen=True) +class RawGetResponse: + """ + A raw GET response produced by a Server.raw_get_handler. + + Lets an application serve arbitrary bytes (e.g. static files) for GET + requests that are not Skyhook function-call dispatches. The body is bytes + (unlike HtmlResponse.body, which is str) so any file type can be served. + """ + body: bytes + status: int = 200 + content_type: str = "application/octet-stream" diff --git a/skyhook/server.py b/skyhook/server.py index a29b6ff..026d947 100644 --- a/skyhook/server.py +++ b/skyhook/server.py @@ -8,6 +8,7 @@ import json import traceback import urllib.parse +from pathlib import Path from datetime import datetime from importlib import reload @@ -17,6 +18,7 @@ from .constants import Constants, Results, ServerCommands, Errors, Ports, HostPrograms, ServerEvents from .modules import core from .logger import Logger +from .responses import HtmlResponse, RawGetResponse logger = Logger() @@ -195,11 +197,15 @@ def __init__( load_modules: List[str] = [], use_main_thread_executor: bool = False, echo_response: bool = True, - only_allow_localhost_connections: bool = True + only_allow_localhost_connections: bool = True, + html_origin_allowlist_provider: Optional[Callable[[], List[str]]] = None, + raw_get_handler: Optional[Callable[[str], Optional[RawGetResponse]]] = None, ) -> None: self.events: EventEmitter = EventEmitter() self.executor_reply: Optional[Dict[str, Any]] = None + self.html_origin_allowlist_provider: Optional[Callable[[], List[str]]] = html_origin_allowlist_provider + self.raw_get_handler: Optional[Callable[[str], Optional[RawGetResponse]]] = raw_get_handler if port: self.port: int = port @@ -347,7 +353,7 @@ def function_not_found(*args: Any, **kwargs: Any) -> None: return function_not_found - def filter_and_execute_function(self, function_name: str, parameters_dict: Dict[str, Any]) -> bytes: + def filter_and_execute_function(self, function_name: str, parameters_dict: Dict[str, Any]) -> Union[bytes, HtmlResponse]: """ This function decides whether or the function call should come from one of the loaded modules or from the server. Every server function should start with SKY_ @@ -356,12 +362,16 @@ def filter_and_execute_function(self, function_name: str, parameters_dict: Dict[ :param function_name: Name of the function to execute :param parameters_dict: Dictionary of parameters to pass to the function - :return: JSON response as bytes + :return: JSON response as bytes, or an HtmlResponse if the handler returned one """ if function_name in dir(ServerCommands): result_json: Dict[str, Any] = self.__process_server_command(function_name, parameters_dict) else: - result_json = self.__process_module_command(function_name, parameters_dict) + result = self.__process_module_command(function_name, parameters_dict) + if isinstance(result, HtmlResponse): + self.executor_reply = None + return result + result_json = result self.executor_reply = None return json.dumps(result_json).encode() @@ -422,7 +432,7 @@ def __process_server_command(self, function_name: str, parameters_dict: Dict[str return result_json - def __process_module_command(self, function_name: str, parameters_dict: Dict[str, Any], timeout: float = 10.0) -> Dict[str, Any]: + def __process_module_command(self, function_name: str, parameters_dict: Dict[str, Any], timeout: float = 10.0) -> Union[Dict[str, Any], HtmlResponse]: """ Processes a command if the function in it was a module function. @@ -431,7 +441,7 @@ def __process_module_command(self, function_name: str, parameters_dict: Dict[str :param function_name: Name of the function to execute :param parameters_dict: Dictionary of parameters for the function :param timeout: Timeout in seconds for waiting for executor reply - :return: Result JSON dictionary + :return: Result JSON dictionary, or HtmlResponse if the handler returned one """ if self.__use_main_thread_executor: logger.debug("Emitting exec_command event") @@ -456,6 +466,8 @@ def __process_module_command(self, function_name: str, parameters_dict: Dict[str return_value: Any = function(**parameters_dict) success: bool = True self.events.emit(ServerEvents.command, function_name, parameters_dict) + if isinstance(return_value, HtmlResponse): + return return_value except Exception as err: trace: str = str(traceback.format_exc()) return_value = trace @@ -512,19 +524,81 @@ def do_GET(self) -> None: if self.path == "/favicon.ico": return + # Static file serving — short-circuit before any command processing + if self.path.startswith("/static/") and self.skyhook_server.static_dir is not None: + filename: str = self.path[len("/static/"):] + file_path: Path = (self.skyhook_server.static_dir / filename).resolve() + static_root: Path = self.skyhook_server.static_dir.resolve() + if not str(file_path).startswith(str(static_root)): + self.send_response(403) + self.end_headers() + return + if not file_path.is_file(): + self.send_response(404) + self.end_headers() + return + content_type, _ = mimetypes.guess_type(str(file_path)) + if content_type is None: + content_type = "application/octet-stream" + self.send_response(200) + self.send_header("Content-type", content_type) + self.end_headers() + self.wfile.write(file_path.read_bytes()) + return + data: str = urllib.parse.unquote(self.path).lstrip("/") parts: List[str] = data.split("&") try: function: str = eval(parts[0]) parameters: Dict[str, Any] = json.loads(parts[1]) - except NameError as err: + except (NameError, SyntaxError, ValueError, IndexError) as err: + # This GET is not a function-call dispatch. Dispatch stays the primary + # path; only on a parse miss do we offer the request to the app's + # optional raw GET handler (e.g. static-file serving). + if self.skyhook_server.raw_get_handler is not None: + raw: Optional[RawGetResponse] = self.skyhook_server.raw_get_handler(self.path) + if raw is not None: + self.send_response(raw.status) + self.send_header("Content-type", raw.content_type) + self.end_headers() + if raw.body: + self.wfile.write(raw.body) + return logger.warning(f"Got a GET request that I don't know what to do with") logger.warning(f"Request was: GET {data}") logger.warning(f"Error is : {err}") return - command_response: bytes = self.skyhook_server.filter_and_execute_function(function, parameters) + # Origin allowlist pre-check for HTML handlers (skip for server commands) + if self.skyhook_server.html_origin_allowlist_provider is not None and function not in dir(ServerCommands): + fn: Callable[..., Any] = self.skyhook_server.get_function_by_name(function) + if getattr(fn, '__skyhook_html_handler__', False): + origin: str = self.headers.get('Origin') or self.headers.get('Referer', '') + allowlist: List[str] = self.skyhook_server.html_origin_allowlist_provider() + if not origin: + pass # no Origin header — allow (same-origin or non-browser caller) + elif allowlist and not any(origin.startswith(a) for a in allowlist): + self.send_response(403) + self.send_header('Content-type', 'text/html; charset=utf-8') + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + self.wfile.write(b"

403 Forbidden

Origin not in allowlist.

") + return + + command_response: Union[bytes, HtmlResponse] = self.skyhook_server.filter_and_execute_function(function, parameters) + + # HtmlResponse dispatch — write verbatim HTML, no auto-close script + if isinstance(command_response, HtmlResponse): + self.send_response(command_response.status) + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + self.send_header('Access-Control-Allow-Headers', 'Content-Type') + self.send_header('Content-type', command_response.content_type) + self.end_headers() + self.wfile.write(command_response.body.encode('utf-8')) + return + self.send_response_data("GET") self.wfile.write(bytes(f"{command_response}".encode("utf-8"))) if self.reply_with_auto_close: @@ -543,7 +617,7 @@ def do_POST(self) -> None: function: str = json_data.get(Constants.function_name, "") parameters: Dict[str, Any] = json_data.get(Constants.parameters, {}) - command_response: bytes = self.skyhook_server.filter_and_execute_function(function, parameters) + command_response: Union[bytes, HtmlResponse] = self.skyhook_server.filter_and_execute_function(function, parameters) self.send_response_data("POST") self.wfile.write(command_response) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_raw_get_handler.py b/tests/test_raw_get_handler.py new file mode 100644 index 0000000..222ff61 --- /dev/null +++ b/tests/test_raw_get_handler.py @@ -0,0 +1,101 @@ +"""Tests for the generic raw_get_handler GET-fallback seam in the server.""" +import socket +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +import pytest + +from skyhook.responses import RawGetResponse +from skyhook.server import Server + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_for_port(port: int, host: str = "127.0.0.1", timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection((host, port), timeout=0.1): + return + except OSError: + time.sleep(0.05) + raise TimeoutError(f"Server on port {port} did not start within {timeout}s") + + +class ServerContext: + def __init__(self, port: int, **server_kwargs): + self.port = port + self._kwargs = server_kwargs + self.server = None + self._thread = None + + def __enter__(self): + self.server = Server(port=self.port, echo_response=False, **self._kwargs) + self._thread = threading.Thread(target=self.server.start_listening, daemon=True) + self._thread.start() + _wait_for_port(self.port) + return self + + def __exit__(self, *_): + if self.server is not None: + self.server.stop_listening() + if self._thread is not None: + self._thread.join(timeout=3) + + +def test_raw_get_handler_serves_non_dispatch_get(): + calls = [] + + def handler(path): + calls.append(path) + return RawGetResponse(b"hello raw", status=200, content_type="text/plain") + + port = _find_free_port() + with ServerContext(port, raw_get_handler=handler) as ctx: + resp = urllib.request.urlopen(f"http://127.0.0.1:{ctx.port}/some/raw/path", timeout=5) + assert resp.status == 200 + assert resp.headers.get("Content-type") == "text/plain" + assert resp.read() == b"hello raw" + assert calls == ["/some/raw/path"] + + +def test_dispatch_call_not_routed_to_raw_get_handler(): + calls = [] + + def handler(path): + calls.append(path) + return RawGetResponse(b"should not happen", status=200) + + port = _find_free_port() + with ServerContext(port, raw_get_handler=handler) as ctx: + # A well-formed dispatch call for an unknown function still PARSES, so it + # goes through dispatch (returns function_not_found JSON), never the seam. + encoded = "/" + urllib.parse.quote('"nonexistent_fn"&{}', safe="") + try: + urllib.request.urlopen(f"http://127.0.0.1:{ctx.port}{encoded}", timeout=5) + except Exception: + pass + assert calls == [] + + +def test_raw_get_handler_none_falls_through(): + calls = [] + + def handler(path): + calls.append(path) + return None + + port = _find_free_port() + with ServerContext(port, raw_get_handler=handler) as ctx: + try: + urllib.request.urlopen(f"http://127.0.0.1:{ctx.port}/definitely/not/a/call", timeout=5) + except Exception: + pass + assert calls == ["/definitely/not/a/call"] diff --git a/tests/test_responses.py b/tests/test_responses.py new file mode 100644 index 0000000..794ef72 --- /dev/null +++ b/tests/test_responses.py @@ -0,0 +1,223 @@ +""" +Tests for HtmlResponse and the server's HTML dispatch path. +""" +import json +import socket +import threading +import types +import urllib.error +import urllib.parse +import urllib.request + +import pytest + +from skyhook.responses import HtmlResponse +from skyhook.server import Server + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _find_free_port() -> int: + """Ask the OS for a free port then immediately release it.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _get(port: int, function_name: str, params: dict | None = None) -> urllib.request.Request: + """Return a urllib response for a GET call to the skyhook server.""" + if params is None: + params = {} + # URL format: /"function_name"&{"key": "value"} + path = f'"{function_name}"&{json.dumps(params)}' + encoded = urllib.parse.quote(path) + url = f"http://127.0.0.1:{port}/{encoded}" + return urllib.request.urlopen(url, timeout=5) + + +class ServerContext: + """ + Spin up a Server instance in a daemon thread; tear it down afterwards. + + Usage:: + + with ServerContext(port, static_dir=...) as ctx: + response = _get(ctx.port, "my_func") + """ + + def __init__(self, port: int, **server_kwargs): + self.port = port + self._server_kwargs = server_kwargs + self.server: Server | None = None + self._thread: threading.Thread | None = None + + def __enter__(self) -> "ServerContext": + self.server = Server(port=self.port, echo_response=False, **self._server_kwargs) + self._thread = threading.Thread(target=self.server.start_listening, daemon=True) + self._thread.start() + # Give the server a moment to bind + _wait_for_port(self.port) + return self + + def __exit__(self, *_): + if self.server is not None: + self.server.stop_listening() + if self._thread is not None: + self._thread.join(timeout=3) + + +def _wait_for_port(port: int, host: str = "127.0.0.1", timeout: float = 5.0) -> None: + """Block until the port is accepting connections, or raise TimeoutError.""" + import time + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection((host, port), timeout=0.1): + return + except OSError: + time.sleep(0.05) + raise TimeoutError(f"Server on port {port} did not start within {timeout}s") + + +# --------------------------------------------------------------------------- +# HtmlResponse unit tests (no server needed) +# --------------------------------------------------------------------------- + +class TestHtmlResponseDefaults: + def test_status_default(self): + r = HtmlResponse(body="

hi

") + assert r.status == 200 + + def test_content_type_default(self): + r = HtmlResponse(body="

hi

") + assert r.content_type == "text/html; charset=utf-8" + + def test_body_stored(self): + r = HtmlResponse(body="

Hello

") + assert r.body == "

Hello

" + + +class TestHtmlResponseCustomValues: + def test_custom_status(self): + r = HtmlResponse(body="not found", status=404) + assert r.status == 404 + + def test_custom_content_type(self): + r = HtmlResponse(body="data", content_type="text/plain; charset=utf-8") + assert r.content_type == "text/plain; charset=utf-8" + + +class TestHtmlResponseFrozen: + def test_frozen_body(self): + from dataclasses import FrozenInstanceError + r = HtmlResponse(body="

x

") + with pytest.raises(FrozenInstanceError): + r.body = "changed" + + def test_frozen_status(self): + from dataclasses import FrozenInstanceError + r = HtmlResponse(body="

x

") + with pytest.raises(FrozenInstanceError): + r.status = 500 + + def test_frozen_content_type(self): + from dataclasses import FrozenInstanceError + r = HtmlResponse(body="

x

") + with pytest.raises(FrozenInstanceError): + r.content_type = "text/plain" + + +# --------------------------------------------------------------------------- +# Integration tests — HtmlResponse dispatch path +# --------------------------------------------------------------------------- + +class TestHtmlDispatch: + """Spin up a real server and make real HTTP requests.""" + + def _make_server_with_html_handler(self): + """Return (port, module) ready for ServerContext.""" + port = _find_free_port() + mod = types.ModuleType("_test_html_mod") + + def my_html_handler(): + return HtmlResponse(body="

Hello

", status=200) + + mod.my_html_handler = my_html_handler + return port, mod + + def test_content_type_header(self): + port, mod = self._make_server_with_html_handler() + with ServerContext(port) as ctx: + ctx.server.hotload_module(mod, is_skyhook_module=False) + resp = _get(ctx.port, "my_html_handler") + ct = resp.headers.get("Content-type", "") + assert ct == "text/html; charset=utf-8" + + def test_body_is_verbatim_html(self): + port, mod = self._make_server_with_html_handler() + with ServerContext(port) as ctx: + ctx.server.hotload_module(mod, is_skyhook_module=False) + resp = _get(ctx.port, "my_html_handler") + body = resp.read().decode("utf-8") + assert "

Hello

" in body + + def test_no_window_close_script(self): + port, mod = self._make_server_with_html_handler() + with ServerContext(port) as ctx: + ctx.server.hotload_module(mod, is_skyhook_module=False) + resp = _get(ctx.port, "my_html_handler") + body = resp.read().decode("utf-8") + assert "window.close()" not in body + assert "window.open" not in body + + def test_http_status_matches_html_response(self): + port = _find_free_port() + mod = types.ModuleType("_test_html_404_mod") + + def page_not_found(): + return HtmlResponse(body="

Not found

", status=404) + + mod.page_not_found = page_not_found + + with ServerContext(port) as ctx: + ctx.server.hotload_module(mod, is_skyhook_module=False) + with pytest.raises(urllib.error.HTTPError) as exc_info: + _get(ctx.port, "page_not_found") + assert exc_info.value.code == 404 + + def test_body_not_json_wrapped(self): + """The body must be raw HTML, not a JSON envelope.""" + port, mod = self._make_server_with_html_handler() + with ServerContext(port) as ctx: + ctx.server.hotload_module(mod, is_skyhook_module=False) + resp = _get(ctx.port, "my_html_handler") + body = resp.read().decode("utf-8") + # Must NOT start with a JSON object + assert not body.strip().startswith("{") + assert not body.strip().startswith('"') + + +# --------------------------------------------------------------------------- +# Regression: plain-dict handlers still return JSON +# --------------------------------------------------------------------------- + +class TestDictHandlerReturnsJson: + def test_dict_handler_is_json(self): + port = _find_free_port() + mod = types.ModuleType("_test_dict_mod") + + def echo_handler(): + return {"msg": "hello"} + + mod.echo_handler = echo_handler + + with ServerContext(port) as ctx: + ctx.server.hotload_module(mod, is_skyhook_module=False) + resp = _get(ctx.port, "echo_handler") + body = resp.read().decode("utf-8") + data = json.loads(body) + # The server wraps the return value in a result envelope + assert data["Success"] is True + assert data["ReturnValue"] == {"msg": "hello"}