From 1b5fa1fd2a9374e6bd45668fe42546dd009eed3f Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Tue, 1 Sep 2026 16:45:07 +0200 Subject: [PATCH] Add support for Odoo 16-19 --- README.md | 4 + aikido_zen/__init__.py | 1 + aikido_zen/context/__init__.py | 2 +- aikido_zen/init_test.py | 21 ++ aikido_zen/sources/odoo/__init__.py | 5 + aikido_zen/sources/odoo/body.py | 26 ++ aikido_zen/sources/odoo/body_test.py | 83 +++++ aikido_zen/sources/odoo/lifecycle.py | 249 +++++++++++++ aikido_zen/sources/odoo/lifecycle_test.py | 422 ++++++++++++++++++++++ aikido_zen/sources/odoo/route.py | 17 + aikido_zen/sources/odoo/route_test.py | 26 ++ docs/odoo.md | 155 ++++++++ 12 files changed, 1010 insertions(+), 1 deletion(-) create mode 100644 aikido_zen/sources/odoo/__init__.py create mode 100644 aikido_zen/sources/odoo/body.py create mode 100644 aikido_zen/sources/odoo/body_test.py create mode 100644 aikido_zen/sources/odoo/lifecycle.py create mode 100644 aikido_zen/sources/odoo/lifecycle_test.py create mode 100644 aikido_zen/sources/odoo/route.py create mode 100644 aikido_zen/sources/odoo/route_test.py create mode 100644 docs/odoo.md diff --git a/README.md b/README.md index dc21f174e..56e90728e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Zen for Python 3 is compatible with: ### WSGI * ✅ [Django](docs/django.md) * ✅ [Flask](docs/flask.md) ^2.2.4 + #### WSGI Servers * ✅ [Gunicorn](docs/gunicorn.md) * ✅ [uWSGI](docs/uwsgi.md) @@ -64,6 +65,9 @@ Zen instruments the following AI SDKs to track which models are used and how man * ✅ [`boto3`](https://pypi.org/project/boto3) (AWS Bedrock) * ✅ [`groq`](https://pypi.org/project/groq) +### Applications +* ✅ [Odoo](docs/odoo.md) 16–19 + Zen is compatible with Python 3.8-3.14 and can run on Windows, Linux, and Mac OS X. ## Reporting to your Aikido Security dashboard diff --git a/aikido_zen/__init__.py b/aikido_zen/__init__.py index 987fa71c4..f5ab8ca9e 100644 --- a/aikido_zen/__init__.py +++ b/aikido_zen/__init__.py @@ -62,6 +62,7 @@ def protect(mode="daemon", token=""): # Import sources import aikido_zen.sources.django import aikido_zen.sources.flask + import aikido_zen.sources.odoo import aikido_zen.sources.quart import aikido_zen.sources.starlette import aikido_zen.sources.fastapi diff --git a/aikido_zen/context/__init__.py b/aikido_zen/context/__init__.py index 04a00e6a5..cc91ad4f5 100644 --- a/aikido_zen/context/__init__.py +++ b/aikido_zen/context/__init__.py @@ -20,7 +20,7 @@ UINPUT_SOURCES = ["body", "cookies", "query", "headers", "xml", "route_params"] current_context = contextvars.ContextVar("current_context", default=None) -WSGI_SOURCES = ["django", "flask"] +WSGI_SOURCES = ["django", "flask", "odoo"] ASGI_SOURCES = ["quart", "django_async", "starlette"] diff --git a/aikido_zen/init_test.py b/aikido_zen/init_test.py index 15087a6d9..b84258c07 100644 --- a/aikido_zen/init_test.py +++ b/aikido_zen/init_test.py @@ -1,3 +1,4 @@ +import builtins from unittest.mock import patch import pytest @@ -31,3 +32,23 @@ def test_protect_does_not_start_without_gil(monkeypatch): test_uds_file_access.assert_not_called() start_background_process.assert_not_called() + + +def test_protect_registers_the_odoo_source(monkeypatch): + imported_modules = [] + original_import = builtins.__import__ + + def track_import(name, *args, **kwargs): + imported_modules.append(name) + return original_import(name, *args, **kwargs) + + monkeypatch.setattr("aikido_zen.aikido_disabled_flag_active", lambda: False) + monkeypatch.setattr("aikido_zen.python_version_not_supported", lambda: False) + monkeypatch.setattr("aikido_zen.gil_not_enabled", lambda: False) + monkeypatch.setattr("aikido_zen.test_uds_file_access", lambda: True) + monkeypatch.setattr("aikido_zen.check_gevent", lambda: False) + monkeypatch.setattr(builtins, "__import__", track_import) + + protect(mode="daemon_disabled") + + assert "aikido_zen.sources.odoo" in imported_modules diff --git a/aikido_zen/sources/odoo/__init__.py b/aikido_zen/sources/odoo/__init__.py new file mode 100644 index 000000000..5ea2cf6cf --- /dev/null +++ b/aikido_zen/sources/odoo/__init__.py @@ -0,0 +1,5 @@ +from aikido_zen.sinks import on_import +from .lifecycle import patch + + +on_import("odoo.http")(patch) diff --git a/aikido_zen/sources/odoo/body.py b/aikido_zen/sources/odoo/body.py new file mode 100644 index 000000000..80e53747b --- /dev/null +++ b/aikido_zen/sources/odoo/body.py @@ -0,0 +1,26 @@ +_FORM_MIMETYPES = { + "application/x-www-form-urlencoded", + "multipart/form-data", +} +_JSON_ROUTE_TYPES = {"json", "jsonrpc", "json2"} + + +def extract_body(request, routing_type): + httprequest = request.httprequest + + if routing_type in _JSON_ROUTE_TYPES: + if routing_type == "json2" and not httprequest.content_length: + return None + return request.get_json_data() + + mimetype = (getattr(httprequest, "mimetype", "") or "").lower() + if mimetype == "application/json" or mimetype.endswith("+json"): + return request.get_json_data() + + if mimetype in _FORM_MIMETYPES: + form = httprequest.form + if hasattr(form, "lists"): + return {key: list(values) for key, values in form.lists()} + return dict(form) + + return httprequest.get_data(cache=True) diff --git a/aikido_zen/sources/odoo/body_test.py b/aikido_zen/sources/odoo/body_test.py new file mode 100644 index 000000000..8df48bcbc --- /dev/null +++ b/aikido_zen/sources/odoo/body_test.py @@ -0,0 +1,83 @@ +import json +from io import BytesIO + +import pytest +from werkzeug.test import EnvironBuilder +from werkzeug.wrappers import Request as WerkzeugRequest + +from .body import extract_body + + +class OdooRequest: + def __init__(self, httprequest): + self.httprequest = httprequest + + def get_json_data(self): + return json.loads(self.httprequest.get_data(as_text=True)) + + +def test_extract_form_preserves_duplicate_values_without_reading_uploads(): + environ = EnvironBuilder( + method="POST", + data={ + "tag": ["first", "second"], + "upload": (BytesIO(b"file contents"), "example.txt"), + }, + ).get_environ() + request = OdooRequest(WerkzeugRequest(environ)) + + body = extract_body(request, "http") + + assert body == {"tag": ["first", "second"]} + assert request.httprequest.form.getlist("tag") == ["first", "second"] + assert request.httprequest.files["upload"].stream.tell() == 0 + + +@pytest.mark.parametrize("routing_type", ["json", "jsonrpc", "json2"]) +def test_extract_json_keeps_the_full_envelope_and_cached_request_data(routing_type): + payload = { + "jsonrpc": "2.0", + "method": "call", + "params": {"command": "echo test", "context": {"lang": "en_US"}}, + "id": 7, + } + encoded_payload = json.dumps(payload).encode() + environ = EnvironBuilder( + method="POST", + data=encoded_payload, + content_type="application/json", + ).get_environ() + request = OdooRequest(WerkzeugRequest(environ)) + + assert extract_body(request, routing_type) == payload + assert request.httprequest.get_data(cache=True) == encoded_payload + + +def test_extract_unstructured_body_uses_the_cached_request_data(): + payload = b"plain text body" + environ = EnvironBuilder( + method="POST", + data=payload, + content_type="text/plain", + ).get_environ() + request = OdooRequest(WerkzeugRequest(environ)) + + assert extract_body(request, "http") == payload + assert request.httprequest.get_data(cache=True) == payload + + +def test_malformed_json_remains_available_for_odoo_to_parse(): + payload = b'{"invalid":' + environ = EnvironBuilder( + method="POST", + data=payload, + content_type="application/json", + ).get_environ() + request = OdooRequest(WerkzeugRequest(environ)) + + with pytest.raises(json.JSONDecodeError): + extract_body(request, "json") + with pytest.raises(json.JSONDecodeError): + request.get_json_data() + + assert request.httprequest.get_data(cache=True) == payload diff --git a/aikido_zen/sources/odoo/lifecycle.py b/aikido_zen/sources/odoo/lifecycle.py new file mode 100644 index 000000000..79a2b8647 --- /dev/null +++ b/aikido_zen/sources/odoo/lifecycle.py @@ -0,0 +1,249 @@ +from aikido_zen.context import Context, current_context, get_current_context +from aikido_zen.errors import AikidoException +from aikido_zen.helpers.get_argument import get_argument +from aikido_zen.helpers.logging import logger +from aikido_zen.sinks import patch_function +from aikido_zen.sources.functions.request_handler import request_handler +from .body import extract_body +from .route import extract_route_arguments + + +_SUPPORTED_MAJOR_VERSIONS = {16, 17, 18, 19} +_PATCH_MARKER = "_aikido_zen_request_source_patched" +_PRE_DISPATCH_MARKER = "_aikido_zen_pre_dispatch_handled" +_POST_DISPATCH_MARKER = "_aikido_zen_post_dispatch_handled" + + +class _ContextPreservingIterable: + def __init__(self, iterable, context): + self.iterable = iterable + self.context = context + self.iterator = None + + def __iter__(self): + return self + + def __next__(self): + token = current_context.set(self.context) + try: + if self.iterator is None: + self.iterator = iter(self.iterable) + return next(self.iterator) + finally: + current_context.reset(token) + + def close(self): + token = current_context.set(self.context) + try: + close = getattr(self.iterable, "close", None) + if not callable(close): + return None + return close() + finally: + current_context.reset(token) + + def __getattr__(self, name): + return getattr(self.iterable, name) + + +def _request_init(wrapped, instance, args, kwargs): + result = wrapped(*args, **kwargs) + try: + httprequest = getattr(instance, "httprequest", None) + environ = getattr(httprequest, "environ", None) + if environ is None: + return result + + context = Context(req=environ, source="odoo") + context.set_as_current_context() + request_handler(stage="init") + except AikidoException: + raise + except Exception: + logger.debug("Failed to initialize the Odoo request context.") + return result + + +def _dispatcher_pre_dispatch(wrapped, instance, args, kwargs): + result = wrapped(*args, **kwargs) + context = get_current_context() + if context is None or context.source != "odoo": + return result + if getattr(context, _PRE_DISPATCH_MARKER, False): + return result + setattr(context, _PRE_DISPATCH_MARKER, True) + + route_arguments = get_argument(args, kwargs, 1, "args") + + try: + context.route_params = extract_route_arguments(route_arguments) + context.parsed_userinput.pop("route_params", None) + except AikidoException: + raise + except Exception: + logger.debug("Failed to extract the Odoo route arguments.") + + try: + body = extract_body(instance.request, getattr(instance, "routing_type", None)) + context.set_body(body) + context.parsed_userinput.pop("body", None) + except AikidoException: + raise + except Exception: + logger.debug("Failed to extract the Odoo request body.") + + response = _request_policy_response(instance.request) + if response is not None: + _abort_with_response(response) + return result + + +def _request_policy_response(request): + try: + pre_response = request_handler(stage="pre_response") + if pre_response is not None: + message, status_code = pre_response + return _make_plain_text_response(request, message, status_code) + + except AikidoException: + raise + except Exception: + logger.debug("Failed to evaluate Odoo request policies.") + return None + + +def _make_plain_text_response(request, message, status_code): + return request.make_response( + message, + headers=[("Content-Type", "text/plain; charset=utf-8")], + status=status_code, + ) + + +def _abort_with_response(response): + try: + from werkzeug.exceptions import HTTPException, abort + except Exception: + logger.debug("Failed to import Werkzeug while blocking an Odoo request.") + return + + try: + abort(response) + except (AikidoException, HTTPException): + raise + except Exception: + logger.debug("Failed to abort a blocked Odoo request.") + + +def _record_response(context, status_code): + if context is None or context.source != "odoo": + return + if getattr(context, _POST_DISPATCH_MARKER, False): + return + + setattr(context, _POST_DISPATCH_MARKER, True) + try: + request_handler(stage="post_response", status_code=status_code) + except AikidoException: + raise + except Exception: + logger.debug("Failed to process the Odoo response.") + + +def _dispatcher_post_dispatch(wrapped, instance, args, kwargs): + result = wrapped(*args, **kwargs) + response = get_argument(args, kwargs, 0, "response") + try: + status_code = getattr(response, "status_code") + except Exception: + logger.debug("Failed to read the Odoo response status.") + return result + + if status_code is not None: + _record_response(get_current_context(), status_code) + return result + + +def _application_call(wrapped, instance, args, kwargs): + start_response = get_argument(args, kwargs, 1, "start_response") + application_args = args + application_kwargs = kwargs + + if callable(start_response): + + def record_start_response(status, headers, *extra): + try: + status_code = int(status.split(" ", 1)[0]) + except (AttributeError, TypeError, ValueError): + logger.debug("Failed to read the Odoo WSGI response status.") + else: + _record_response(get_current_context(), status_code) + return start_response(status, headers, *extra) + + if "start_response" in kwargs: + application_kwargs = {**kwargs, "start_response": record_start_response} + elif len(args) > 1: + application_args = (*args[:1], record_start_response, *args[2:]) + + previous_context = get_current_context() + entry_token = current_context.set(previous_context) + try: + iterable = wrapped(*application_args, **application_kwargs) + request_context = get_current_context() + finally: + current_context.reset(entry_token) + + if request_context is previous_context: + return iterable + if request_context is None or request_context.source != "odoo": + return iterable + return _ContextPreservingIterable(iterable, request_context) + + +def _get_major_version(http_module): + try: + return int(http_module.odoo.release.version_info[0]) + except (AttributeError, IndexError, TypeError, ValueError): + return None + + +def _has_required_lifecycle(http_module): + required_methods = ( + ("Request", "__init__"), + ("Dispatcher", "pre_dispatch"), + ("Dispatcher", "post_dispatch"), + ("Application", "__call__"), + ) + return all( + callable(getattr(getattr(http_module, class_name, None), method_name, None)) + for class_name, method_name in required_methods + ) + + +def is_patched(http_module): + return getattr(http_module, _PATCH_MARKER, False) is True + + +def patch(http_module): + if is_patched(http_module): + return True + + major_version = _get_major_version(http_module) + if major_version not in _SUPPORTED_MAJOR_VERSIONS: + logger.warning( + "Odoo request protection disabled: supported Odoo versions are 16 through 19." + ) + return False + + if not _has_required_lifecycle(http_module): + logger.warning( + "Odoo request protection disabled: required HTTP lifecycle hooks are unavailable." + ) + return False + + patch_function(http_module, "Request.__init__", _request_init) + patch_function(http_module, "Dispatcher.pre_dispatch", _dispatcher_pre_dispatch) + patch_function(http_module, "Dispatcher.post_dispatch", _dispatcher_post_dispatch) + patch_function(http_module, "Application.__call__", _application_call) + setattr(http_module, _PATCH_MARKER, True) + return True diff --git a/aikido_zen/sources/odoo/lifecycle_test.py b/aikido_zen/sources/odoo/lifecycle_test.py new file mode 100644 index 000000000..38a381352 --- /dev/null +++ b/aikido_zen/sources/odoo/lifecycle_test.py @@ -0,0 +1,422 @@ +import json +from types import SimpleNamespace +from unittest.mock import Mock, call, patch as mock_patch + +import pytest +from werkzeug.exceptions import HTTPException +from werkzeug.test import EnvironBuilder +from werkzeug.wrappers import Request as WerkzeugRequest +from werkzeug.wrappers import Response + +from aikido_zen.context import Context, current_context, get_current_context +from aikido_zen.errors import AikidoException +from aikido_zen.helpers.extract_strings_from_context import extract_strings_from_context +from aikido_zen.sinks import patch_function +from .lifecycle import patch + + +def make_environ(data=None, content_type=None, path="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/items/7"): + return EnvironBuilder( + method="POST", + path=path, + query_string={"search": "value"}, + headers={"X-Test": "header", "Cookie": "session_id=secret"}, + data=data, + content_type=content_type, + base_url="https://example.com", + environ_base={"REMOTE_ADDR": "198.51.100.23"}, + ).get_environ() + + +def make_http_module(version=16): + class Request: + init_calls = 0 + + def __init__(self, httprequest): + type(self).init_calls += 1 + self.httprequest = httprequest + + def get_json_data(self): + return json.loads(self.httprequest.get_data(as_text=True)) + + def make_response(self, data, headers=None, status=200): + return Response(data, headers=headers, status=status) + + class Dispatcher: + routing_type = "http" + + def __init__(self, request): + self.request = request + self.pre_calls = [] + self.post_calls = [] + self.pre_result = object() + self.post_result = object() + + def pre_dispatch(self, rule, arguments): + self.pre_calls.append((rule, arguments)) + return self.pre_result + + def post_dispatch(self, response): + self.post_calls.append(response) + return self.post_result + + class Application: + def __init__(self, callback=lambda _environ, _start_response: []): + self.callback = callback + + def __call__(self, environ, start_response): + return self.callback(environ, start_response) + + return SimpleNamespace( + Request=Request, + Dispatcher=Dispatcher, + Application=Application, + odoo=SimpleNamespace( + release=SimpleNamespace(version_info=(version, 0, 0, "final", 0, "")) + ), + ) + + +def make_rule(route="/items//"): + return SimpleNamespace(rule=route) + + +@pytest.fixture(autouse=True) +def reset_context(): + current_context.set(None) + yield + current_context.set(None) + + +@pytest.mark.parametrize("version", [15, 20, None]) +def test_patch_fails_open_for_unsupported_or_malformed_versions(version, caplog): + module = make_http_module(16) + module.odoo.release.version_info = None if version is None else (version, 0) + + patch(module) + module.Request(WerkzeugRequest(make_environ())) + + assert get_current_context() is None + assert "Odoo request protection disabled" in caplog.text + + +def test_patch_fails_open_when_a_required_lifecycle_hook_is_missing(caplog): + module = make_http_module() + del module.Dispatcher.post_dispatch + + patch(module) + module.Request(WerkzeugRequest(make_environ())) + + assert get_current_context() is None + assert "Odoo request protection disabled" in caplog.text + + +@pytest.mark.parametrize("version", [16, 17, 18, 19]) +def test_request_initialization_sets_wsgi_context_after_odoo_initialization(version): + module = make_http_module(version) + + with mock_patch("aikido_zen.sources.odoo.lifecycle.request_handler") as handler: + patch(module) + request = module.Request(WerkzeugRequest(make_environ())) + + context = get_current_context() + assert request.httprequest.method == "POST" + assert context.source == "odoo" + assert context.method == "POST" + assert context.url == "https://example.com/items/7" + assert context.query == {"search": ["value"]} + assert context.headers.get_header("X_TEST") == "header" + assert context.cookies == {"session_id": "secret"} + assert context.remote_address == "198.51.100.23" + handler.assert_called_once_with(stage="init") + + +def test_pre_dispatch_enriches_context_without_mutating_odoo_arguments(): + module = make_http_module() + environ = make_environ( + data={"tag": ["first", "second"]}, + content_type="application/x-www-form-urlencoded", + ) + + with mock_patch( + "aikido_zen.sources.odoo.lifecycle.request_handler", return_value=None + ) as handler: + patch(module) + request = module.Request(WerkzeugRequest(environ)) + dispatcher = module.Dispatcher(request) + rule = make_rule() + arguments = {"item_id": 7, "slug": "actual-route", "record": object()} + original_arguments = arguments.copy() + get_current_context().parsed_userinput = { + "body": {"stale-body": ""}, + "route_params": {"stale-route": ""}, + } + + result = dispatcher.pre_dispatch(rule, arguments) + + context = get_current_context() + assert result is dispatcher.pre_result + assert dispatcher.pre_calls == [(rule, arguments)] + assert arguments == original_arguments + assert context.route == "/items/:number" + assert context.route_params == {"item_id": 7, "slug": "actual-route"} + assert context.body == {"tag": ["first", "second"]} + assert context.user is None + extracted_values = { + value for value, _path, _source in extract_strings_from_context(context) + } + assert "actual-route" in extracted_values + assert "first" in extracted_values + assert "stale-body" not in extracted_values + assert "stale-route" not in extracted_values + assert handler.call_args_list == [call(stage="init"), call(stage="pre_response")] + + +def test_pre_dispatch_enrichment_runs_at_most_once(): + module = make_http_module() + + with mock_patch( + "aikido_zen.sources.odoo.lifecycle.extract_body" + ) as extract_request_body, mock_patch( + "aikido_zen.sources.odoo.lifecycle.request_handler", return_value=None + ) as handler: + patch(module) + request = module.Request(WerkzeugRequest(make_environ())) + dispatcher = module.Dispatcher(request) + rule = make_rule() + + dispatcher.pre_dispatch(rule, {}) + dispatcher.pre_dispatch(rule, {}) + + assert dispatcher.pre_calls == [(rule, {}), (rule, {})] + extract_request_body.assert_called_once_with(request, "http") + assert handler.call_args_list == [call(stage="init"), call(stage="pre_response")] + + +def test_pre_dispatch_aborts_before_controller_for_request_policy_blocks(): + module = make_http_module() + + def handle_request(stage, status_code=0): + if stage == "pre_response": + return "Your IP address is blocked.", 403 + return None + + with mock_patch( + "aikido_zen.sources.odoo.lifecycle.request_handler", + side_effect=handle_request, + ): + patch(module) + request = module.Request(WerkzeugRequest(make_environ())) + dispatcher = module.Dispatcher(request) + + with pytest.raises(HTTPException) as raised: + dispatcher.pre_dispatch(make_rule(), {}) + + response = raised.value.get_response() + assert dispatcher.pre_calls == [(dispatcher.pre_calls[0][0], {})] + assert response.status_code == 403 + assert response.get_data(as_text=True) == "Your IP address is blocked." + + +def test_request_policy_abort_failure_does_not_break_the_odoo_request(): + module = make_http_module() + + with mock_patch( + "aikido_zen.sources.odoo.lifecycle.request_handler", + side_effect=lambda stage, status_code=0: ( + ("Blocked", 403) if stage == "pre_response" else None + ), + ), mock_patch( + "werkzeug.exceptions.abort", side_effect=RuntimeError("abort failed") + ): + patch(module) + request = module.Request(WerkzeugRequest(make_environ())) + dispatcher = module.Dispatcher(request) + + result = dispatcher.pre_dispatch(make_rule(), {}) + + assert result is dispatcher.pre_result + assert len(dispatcher.pre_calls) == 1 + + +def test_pre_dispatch_failure_does_not_break_the_odoo_request(caplog): + module = make_http_module() + + with mock_patch( + "aikido_zen.sources.odoo.lifecycle.request_handler", return_value=None + ), mock_patch( + "aikido_zen.sources.odoo.lifecycle.extract_body", + side_effect=ValueError("malformed body containing a secret"), + ): + patch(module) + request = module.Request(WerkzeugRequest(make_environ())) + dispatcher = module.Dispatcher(request) + + result = dispatcher.pre_dispatch(make_rule(), {}) + + assert result is dispatcher.pre_result + assert len(dispatcher.pre_calls) == 1 + assert "malformed body containing a secret" not in caplog.text + + +def test_post_dispatch_records_a_response_at_most_once(): + module = make_http_module() + + with mock_patch("aikido_zen.sources.odoo.lifecycle.request_handler") as handler: + patch(module) + request = module.Request(WerkzeugRequest(make_environ())) + dispatcher = module.Dispatcher(request) + response = Response(status=201) + handler.reset_mock() + + first_result = dispatcher.post_dispatch(response) + second_result = dispatcher.post_dispatch(response) + + assert first_result is dispatcher.post_result + assert second_result is dispatcher.post_result + assert dispatcher.post_calls == [response, response] + handler.assert_called_once_with(stage="post_response", status_code=201) + + +@pytest.mark.parametrize( + ("path", "status"), + [ + ("/web/static/src/img/logo.png", "200 OK"), + ("/.env", "404 Not Found"), + ], +) +def test_application_records_responses_that_bypass_dispatcher(path, status): + module = make_http_module() + + def application(environ, start_response): + module.Request(WerkzeugRequest(environ)) + start_response(status, []) + return [] + + with mock_patch("aikido_zen.sources.odoo.lifecycle.request_handler") as handler: + patch(module) + response = module.Application(application)( + make_environ(path=path), + Mock(), + ) + + assert list(response) == [] + handler.assert_has_calls( + [ + call(stage="init"), + call(stage="post_response", status_code=int(status[:3])), + ] + ) + + +def test_application_restores_context_and_reactivates_it_while_streaming(): + module = make_http_module() + previous_context = Context() + request_context = Context() + request_context.source = "odoo" + chunk = b"response chunk" + iterator_error = RuntimeError("stream failed") + + class Stream: + def __init__(self): + self.iterated_in = None + self.next_contexts = [] + self.closed_in = None + self.next_calls = 0 + + def __iter__(self): + self.iterated_in = get_current_context() + return self + + def __next__(self): + self.next_contexts.append(get_current_context()) + self.next_calls += 1 + if self.next_calls == 1: + return chunk + raise iterator_error + + def close(self): + self.closed_in = get_current_context() + return "closed" + + stream = Stream() + + def application(_environ, _start_response): + request_context.set_as_current_context() + return stream + + current_context.set(previous_context) + patch(module) + result = module.Application(application)(make_environ(), Mock()) + + assert get_current_context() is previous_context + assert next(result) is chunk + assert get_current_context() is previous_context + with pytest.raises(RuntimeError) as raised: + next(result) + assert raised.value is iterator_error + assert get_current_context() is previous_context + assert result.close() == "closed" + assert stream.iterated_in is request_context + assert stream.next_contexts == [request_context, request_context] + assert stream.closed_in is request_context + assert get_current_context() is previous_context + + +def test_application_restores_context_when_odoo_raises(): + module = make_http_module() + previous_context = Context() + request_context = Context() + request_context.source = "odoo" + application_error = AikidoException("blocked") + + def application(_environ, _start_response): + request_context.set_as_current_context() + raise application_error + + current_context.set(previous_context) + patch(module) + + with pytest.raises(AikidoException) as raised: + module.Application(application)(make_environ(), Mock()) + + assert raised.value is application_error + assert get_current_context() is previous_context + + +@pytest.mark.parametrize("zen_first", [True, False]) +def test_patch_coexists_with_another_wrapper(zen_first): + module = make_http_module() + observed_calls = [] + + def observer(wrapped, instance, args, kwargs): + observed_calls.append((args, kwargs)) + return wrapped(*args, **kwargs) + + if zen_first: + patch(module) + patch_function(module, "Dispatcher.pre_dispatch", observer) + else: + patch_function(module, "Dispatcher.pre_dispatch", observer) + patch(module) + + with mock_patch( + "aikido_zen.sources.odoo.lifecycle.request_handler", return_value=None + ): + request = module.Request(WerkzeugRequest(make_environ())) + dispatcher = module.Dispatcher(request) + dispatcher.pre_dispatch(make_rule(), {}) + + assert len(observed_calls) == 1 + assert len(dispatcher.pre_calls) == 1 + + +def test_repeated_patch_does_not_duplicate_lifecycle_handlers(): + module = make_http_module() + + with mock_patch("aikido_zen.sources.odoo.lifecycle.request_handler") as handler: + patch(module) + patch(module) + module.Request(WerkzeugRequest(make_environ())) + + handler.assert_called_once_with(stage="init") diff --git a/aikido_zen/sources/odoo/route.py b/aikido_zen/sources/odoo/route.py new file mode 100644 index 000000000..e491635ae --- /dev/null +++ b/aikido_zen/sources/odoo/route.py @@ -0,0 +1,17 @@ +from uuid import UUID + + +_SAFE_ROUTE_ARGUMENT_TYPES = (str, int, float, bool) + + +def extract_route_arguments(arguments): + if not hasattr(arguments, "items"): + return {} + + result = {} + for name, value in arguments.items(): + if value is None or type(value) in _SAFE_ROUTE_ARGUMENT_TYPES: + result[name] = value + elif isinstance(value, UUID): + result[name] = str(value) + return result diff --git a/aikido_zen/sources/odoo/route_test.py b/aikido_zen/sources/odoo/route_test.py new file mode 100644 index 000000000..acc1a2534 --- /dev/null +++ b/aikido_zen/sources/odoo/route_test.py @@ -0,0 +1,26 @@ +from uuid import UUID + +from .route import extract_route_arguments + + +def test_extract_route_arguments_ignores_objects_that_may_access_the_database(): + class Recordset: + def __str__(self): + raise AssertionError("recordset must not be stringified") + + object_id = UUID("12345678-1234-5678-9234-567812345678") + arguments = { + "name": "example", + "page": 3, + "published": True, + "object_id": object_id, + "partner": Recordset(), + "nested": {"value": "ignored"}, + } + + assert extract_route_arguments(arguments) == { + "name": "example", + "page": 3, + "published": True, + "object_id": str(object_id), + } diff --git a/docs/odoo.md b/docs/odoo.md new file mode 100644 index 000000000..00ce682b9 --- /dev/null +++ b/docs/odoo.md @@ -0,0 +1,155 @@ +# Odoo + +Zen supports self-hosted Odoo Community 16 through 19 on Odoo's regular HTTP server in both threaded and prefork worker modes. Odoo's gevent server, websocket traffic, long-polling traffic, Odoo Online, and Odoo.sh are not supported. + +## Installation + +Choose the instructions that match your Odoo deployment. + +### Docker + +Extend the Odoo image used by your deployment: + +```dockerfile +FROM odoo:19.0 + +USER root +RUN python3 -m pip install --no-cache-dir --target /opt/aikido-zen aikido_zen +ENV PYTHONPATH=/opt/aikido-zen +USER odoo +``` + +Use the same Odoo version as your existing image, then rebuild and deploy it. + +### Source installation + +Install Zen in the virtual environment used to run `odoo-bin`: + +```sh +/path/to/venv/bin/python -m pip install aikido_zen +``` + +### Linux package + +Install Zen in a separate directory: + +```sh +sudo /usr/bin/python3 -m pip install --target /opt/aikido-zen aikido_zen +``` + +Add `/opt/aikido-zen` to `PYTHONPATH` in the Odoo service configuration. + +Set the Aikido token in the Odoo environment: + +```env +AIKIDO_TOKEN="AIK_RUNTIME_YOUR_TOKEN_HERE" +``` + +## Load Zen when Odoo starts + +Create a server-wide addon named `aikido_zen_bootstrap`. + +`aikido_zen_bootstrap/__init__.py`: + +```python +from .hooks import post_load +``` + +`aikido_zen_bootstrap/__manifest__.py`: + +```python +{ + "name": "Aikido Zen Bootstrap", + "version": "1.0.0", + "license": "AGPL-3", + "depends": ["base"], + "post_load": "post_load", + "installable": True, + "application": False, +} +``` + +`aikido_zen_bootstrap/hooks.py`: + +```python +import aikido_zen + + +def post_load(): + aikido_zen.protect() +``` + +Add the addon directory to `addons_path` and load it as a server-wide module: + +```sh +odoo \ + --addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons \ + --load=base,web,aikido_zen_bootstrap +``` + +Restart Odoo after adding the server-wide module. + +## Blocking mode + +Zen reports attacks without blocking by default. Enable sink blocking after validating the integration in staging: + +```env +AIKIDO_BLOCK=true +``` + +## Rate limiting and user blocking + +Zen does not enable rate limiting or user blocking automatically. To enable them, add a post-authentication policy check to an addon installed in each protected database. + +`your_addon/models/ir_http.py`: + +```python +from odoo import models +from odoo.http import request +from werkzeug.exceptions import abort + +from aikido_zen import set_user +from aikido_zen.middleware import should_block_request + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + @classmethod + def _pre_dispatch(cls, rule, args): + super()._pre_dispatch(rule, args) + + if request.env.uid and not request.env.user.is_public: + set_user({"id": request.env.user.id}) + + result = should_block_request() + if result["block"] is not True: + return + + if result["type"] == "blocked": + message = "You are blocked by Zen." + status = 403 + elif result["type"] == "ratelimited": + message = "You are rate limited by Zen." + if result["trigger"] == "ip" and result["ip"]: + message += f" (Your IP: {result['ip']})" + status = 429 + else: + return + + abort( + request.make_response( + message, + headers=[("Content-Type", "text/plain; charset=utf-8")], + status=status, + ) + ) +``` + +Set the user identity that matches your authorization model before calling `should_block_request()`. You can also call `set_rate_limit_group()` there for group-based rate limits. See [users and rate limiting](user.md) for the available APIs. + +## Limitations + +- Only Odoo 16, 17, 18, and 19 are supported. +- Uploaded file contents are not inspected. +- Request policies are not applied to static files or unmatched routes, but Zen still detects attack waves for those requests.