From 01e751aa516a9fe701d0a5a07f626e25b91ed854 Mon Sep 17 00:00:00 2001 From: Erwin Junge Date: Tue, 24 May 2022 17:20:03 +0200 Subject: [PATCH 1/3] Add `public_discovery` option to taxii2 config This enables unauthenticated access to `/taxii2/` --- CHANGES.rst | 4 ++++ opentaxii/config.py | 1 + opentaxii/server.py | 4 +++- tests/taxii2/test_taxii2_discovery.py | 30 ++++++++++++++++++++++++--- tests/test_config.py | 3 +++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 34025106..8d48b015 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,10 @@ Changelog ========= +0.6.0 (2022-??-??) +------------------ +* Add `public_discovery` option to taxii2 config + 0.5.0 (2022-05-24) ------------------ * Add support for publicly readable taxii 2 collections diff --git a/opentaxii/config.py b/opentaxii/config.py index 63f76584..54484e5d 100644 --- a/opentaxii/config.py +++ b/opentaxii/config.py @@ -62,6 +62,7 @@ class ServerConfig(dict): "description", "max_content_length", "title", + "public_discovery", ) ALL_VALID_OPTIONS = VALID_BASE_OPTIONS + VALID_TAXII_OPTIONS + VALID_TAXII1_OPTIONS diff --git a/opentaxii/server.py b/opentaxii/server.py index d64a2c13..ea31827b 100644 --- a/opentaxii/server.py +++ b/opentaxii/server.py @@ -468,8 +468,10 @@ def handle_request(self, endpoint: Callable[[], Response]): self.check_headers(endpoint) return endpoint() - @register_handler(r"^/taxii2/$") + @register_handler(r"^/taxii2/$", handles_own_auth=True) def discovery_handler(self): + if context.account is None and not self.config["public_discovery"]: + raise Unauthorized() response = { "title": self.config["title"], } diff --git a/tests/taxii2/test_taxii2_discovery.py b/tests/taxii2/test_taxii2_discovery.py index 52837b61..c2dbaf3e 100644 --- a/tests/taxii2/test_taxii2_discovery.py +++ b/tests/taxii2/test_taxii2_discovery.py @@ -149,11 +149,35 @@ def test_discovery( assert json.loads(response.data) == expected_content +@pytest.mark.parametrize("public_discovery", [True, False]) @pytest.mark.parametrize("method", ["get", "post", "delete"]) def test_discovery_unauthenticated( client, method, + public_discovery, ): - func = getattr(client, method) - response = func("/taxii2/") - assert response.status_code == 401 + if public_discovery: + if method == "get": + expected_status_code = 200 + else: + expected_status_code = 405 + else: + if method == "get": + expected_status_code = 401 + else: + expected_status_code = 405 + with patch.object( + client.application.taxii_server.servers.taxii2, + "config", + { + **client.application.taxii_server.servers.taxii2.config, + "title": "Some TAXII Server", + "public_discovery": public_discovery, + }, + ): + func = getattr(client, method) + response = func( + "/taxii2/", + headers={"Accept": "application/taxii+json;version=2.1"}, + ) + assert response.status_code == expected_status_code diff --git a/tests/test_config.py b/tests/test_config.py index b075e23f..8c9e46c8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -37,6 +37,7 @@ a: 1 b: 2 max_content_length: 1024 + public_discovery: true """ TAXII2_CONFIG = """ --- @@ -55,6 +56,7 @@ a: 1 b: 2 max_content_length: 1024 + public_discovery: true """ DEFAULT_BASE_VALUES = { "domain": "localhost:9000", @@ -99,6 +101,7 @@ }, }, "max_content_length": 1024, + "public_discovery": True, } EXPECTED_VALUES = { BACKWARDS_COMPAT_CONFIG: { From cb1bd99b524c89864abc7b7531c174702bdd0536 Mon Sep 17 00:00:00 2001 From: Erwin Junge Date: Wed, 25 May 2022 11:53:05 +0200 Subject: [PATCH 2/3] Add support for publicly readable taxii 2 api roots --- CHANGES.rst | 1 + opentaxii/persistence/sqldb/api.py | 7 +++- opentaxii/persistence/sqldb/taxii2models.py | 1 + opentaxii/server.py | 14 ++++++-- opentaxii/taxii2/entities.py | 6 +++- tests/taxii2/test_taxii2_api_root.py | 40 +++++++++++++++++---- tests/taxii2/test_taxii2_collections.py | 32 +++++++++++++++-- tests/taxii2/utils.py | 10 +++--- 8 files changed, 92 insertions(+), 19 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8d48b015..6af5bc91 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changelog 0.6.0 (2022-??-??) ------------------ * Add `public_discovery` option to taxii2 config +* Add support for publicly readable taxii 2 api roots 0.5.0 (2022-05-24) ------------------ diff --git a/opentaxii/persistence/sqldb/api.py b/opentaxii/persistence/sqldb/api.py index 0d4e1e21..3f1dc613 100644 --- a/opentaxii/persistence/sqldb/api.py +++ b/opentaxii/persistence/sqldb/api.py @@ -520,6 +520,7 @@ def get_api_roots(self) -> List[entities.ApiRoot]: default=obj.default, title=obj.title, description=obj.description, + is_public=obj.is_public, ) for obj in query.all() ] @@ -536,6 +537,7 @@ def get_api_root(self, api_root_id: str) -> Optional[entities.ApiRoot]: default=api_root.default, title=api_root.title, description=api_root.description, + is_public=api_root.is_public, ) else: return None @@ -545,6 +547,7 @@ def add_api_root( title: str, description: Optional[str] = None, default: Optional[bool] = False, + is_public: bool = False, ) -> entities.ApiRoot: """ Add a new api root. @@ -552,11 +555,12 @@ def add_api_root( :param str title: Title of the new api root :param str description: [Optional] Description of the new api root :param bool default: [Optional, False] If the new api should be the default + :param bool is_public: whether this is a publicly readable API root :return: The added ApiRoot entity. """ api_root = taxii2models.ApiRoot( - title=title, description=description, default=False + title=title, description=description, default=default, is_public=is_public ) self.db.session.add(api_root) self.db.session.commit() @@ -567,6 +571,7 @@ def add_api_root( default=api_root.default, title=api_root.title, description=api_root.description, + is_public=is_public, ) def get_job_and_details( diff --git a/opentaxii/persistence/sqldb/taxii2models.py b/opentaxii/persistence/sqldb/taxii2models.py index 51ee1fb7..6874d4ea 100644 --- a/opentaxii/persistence/sqldb/taxii2models.py +++ b/opentaxii/persistence/sqldb/taxii2models.py @@ -21,6 +21,7 @@ class ApiRoot(Base): default = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False) title = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True) description = sqlalchemy.Column(sqlalchemy.Text) + is_public = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False) collections = relationship("Collection", back_populates="api_root") diff --git a/opentaxii/server.py b/opentaxii/server.py index ea31827b..96650c6d 100644 --- a/opentaxii/server.py +++ b/opentaxii/server.py @@ -484,12 +484,16 @@ def discovery_handler(self): response["api_roots"] = [f"/{api_root.id}/" for api_root in api_roots] return make_taxii2_response(response) - @register_handler(r"^/(?P[^/]+)/$") + @register_handler(r"^/(?P[^/]+)/$", handles_own_auth=True) def api_root_handler(self, api_root_id): try: api_root = self.persistence.get_api_root(api_root_id=api_root_id) except DoesNotExistError: + if context.account is None: + raise Unauthorized() raise NotFound() + if context.account is None and not api_root.is_public: + raise Unauthorized() response = { "title": api_root.title, "versions": ["application/taxii+json;version=2.1"], @@ -527,12 +531,16 @@ def job_handler(self, api_root_id, job_id): } return make_taxii2_response(response) - @register_handler(r"^/(?P[^/]+)/collections/$") + @register_handler(r"^/(?P[^/]+)/collections/$", handles_own_auth=True) def collections_handler(self, api_root_id): try: - self.persistence.get_api_root(api_root_id=api_root_id) + api_root = self.persistence.get_api_root(api_root_id=api_root_id) except DoesNotExistError: + if context.account is None: + raise Unauthorized() raise NotFound() + if context.account is None and not api_root.is_public: + raise Unauthorized() collections = self.persistence.get_collections(api_root_id=api_root_id) response = {} if collections: diff --git a/opentaxii/taxii2/entities.py b/opentaxii/taxii2/entities.py index 1f6494e8..da0160e4 100644 --- a/opentaxii/taxii2/entities.py +++ b/opentaxii/taxii2/entities.py @@ -15,14 +15,18 @@ class ApiRoot(Entity): :param bool default: indicator of default api root, should only be True once :param str title: human readable plain text name used to identify this API Root :param str description: human readable plain text description for this API Root + :param bool is_public: whether this is a publicly readable API root """ - def __init__(self, id: str, default: bool, title: str, description: str): + def __init__( + self, id: str, default: bool, title: str, description: str, is_public: bool + ): """Initialize ApiRoot.""" self.id = id self.default = default self.title = title self.description = description + self.is_public = is_public class Collection(Entity): diff --git a/tests/taxii2/test_taxii2_api_root.py b/tests/taxii2/test_taxii2_api_root.py index d81e3bc3..eddaee9a 100644 --- a/tests/taxii2/test_taxii2_api_root.py +++ b/tests/taxii2/test_taxii2_api_root.py @@ -198,23 +198,46 @@ def test_api_root( assert content == expected_content +@pytest.mark.parametrize("is_public", [True, False]) @pytest.mark.parametrize("method", ["get", "post", "delete"]) def test_api_root_unauthenticated( client, method, + is_public, ): - func = getattr(client, method) - response = func(f"/{API_ROOTS[0].id}/") - assert response.status_code == 401 + if is_public: + api_root_id = API_ROOTS[1].id + if method == "get": + expected_status_code = 200 + else: + expected_status_code = 405 + else: + api_root_id = API_ROOTS[0].id + if method == "get": + expected_status_code = 401 + else: + expected_status_code = 405 + with patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_api_root", + side_effect=GET_API_ROOT_MOCK, + ): + func = getattr(client, method) + response = func( + f"/{api_root_id}/", + headers={"Accept": "application/taxii+json;version=2.1"}, + ) + assert response.status_code == expected_status_code @pytest.mark.parametrize( - ["title", "description", "default", "db_api_roots"], + ["title", "description", "default", "is_public", "db_api_roots"], [ pytest.param( "my new api root", # title None, # description False, # default + False, # is_public [], # db_api_roots id="title only", ), @@ -222,6 +245,7 @@ def test_api_root_unauthenticated( "my new api root", # title "my description", # description False, # default + True, # is_public [], # db_api_roots id="title, description", ), @@ -229,6 +253,7 @@ def test_api_root_unauthenticated( "my new api root", # title None, # description True, # default + False, # is_public [], # db_api_roots id="title, default", ), @@ -236,20 +261,22 @@ def test_api_root_unauthenticated( "my new api root", # title "my description", # description True, # default + True, # is_public API_ROOTS_WITH_DEFAULT, # db_api_roots id="title, description, default, existing", ), ], indirect=["db_api_roots"], ) -def test_add_api_root(app, title, description, default, db_api_roots): +def test_add_api_root(app, title, description, default, is_public, db_api_roots): api_root = app.taxii_server.servers.taxii2.persistence.api.add_api_root( - title, description, default + title, description, default, is_public ) assert api_root.id is not None assert api_root.title == title assert api_root.description == description assert api_root.default == default + assert api_root.is_public == is_public db_api_root = ( app.taxii_server.servers.taxii2.persistence.api.db.session.query( taxii2models.ApiRoot @@ -260,6 +287,7 @@ def test_add_api_root(app, title, description, default, db_api_roots): assert db_api_root.title == title assert db_api_root.description == description assert db_api_root.default == default + assert db_api_root.is_public == is_public if default: assert ( app.taxii_server.servers.taxii2.persistence.api.db.session.query( diff --git a/tests/taxii2/test_taxii2_collections.py b/tests/taxii2/test_taxii2_collections.py index 13f04c8d..7e47a4f1 100644 --- a/tests/taxii2/test_taxii2_collections.py +++ b/tests/taxii2/test_taxii2_collections.py @@ -228,11 +228,37 @@ def test_collections( assert content == expected_content +@pytest.mark.parametrize("is_public", [True, False]) @pytest.mark.parametrize("method", ["get", "post", "delete"]) def test_collections_unauthenticated( client, method, + is_public, ): - func = getattr(client, method) - response = func(f"/{API_ROOTS[0].id}/collections/") - assert response.status_code == 401 + if is_public: + api_root_id = API_ROOTS[1].id + if method == "get": + expected_status_code = 200 + else: + expected_status_code = 405 + else: + api_root_id = API_ROOTS[0].id + if method == "get": + expected_status_code = 401 + else: + expected_status_code = 405 + with patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_api_root", + side_effect=GET_API_ROOT_MOCK, + ), patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_collections", + side_effect=GET_COLLECTIONS_MOCK, + ): + func = getattr(client, method) + response = func( + f"/{api_root_id}/collections/", + headers={"Accept": "application/taxii+json;version=2.1"}, + ) + assert response.status_code == expected_status_code diff --git a/tests/taxii2/utils.py b/tests/taxii2/utils.py index 1f43f63f..3450cc3d 100644 --- a/tests/taxii2/utils.py +++ b/tests/taxii2/utils.py @@ -10,13 +10,13 @@ from opentaxii.taxii2.utils import DATETIMEFORMAT, taxii2_datetimeformat API_ROOTS_WITH_DEFAULT = ( - ApiRoot(str(uuid4()), True, "first title", "first description"), - ApiRoot(str(uuid4()), False, "second title", "second description"), + ApiRoot(str(uuid4()), True, "first title", "first description", False), + ApiRoot(str(uuid4()), False, "second title", "second description", True), ) API_ROOTS_WITHOUT_DEFAULT = ( - ApiRoot(str(uuid4()), False, "first title", "first description"), - ApiRoot(str(uuid4()), False, "second title", "second description"), - ApiRoot(str(uuid4()), False, "third title", None), + ApiRoot(str(uuid4()), False, "first title", "first description", False), + ApiRoot(str(uuid4()), False, "second title", "second description", True), + ApiRoot(str(uuid4()), False, "third title", None, False), ) API_ROOTS = API_ROOTS_WITHOUT_DEFAULT NOW = datetime.datetime.now(datetime.timezone.utc) From d500dfa7040d6842a0c7ba11711ff9f48bbd955c Mon Sep 17 00:00:00 2001 From: Erwin Junge Date: Wed, 25 May 2022 11:54:44 +0200 Subject: [PATCH 3/3] Bump version --- CHANGES.rst | 2 +- opentaxii/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6af5bc91..a2dffe43 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Changelog ========= -0.6.0 (2022-??-??) +0.6.0 (2022-05-25 ------------------ * Add `public_discovery` option to taxii2 config * Add support for publicly readable taxii 2 api roots diff --git a/opentaxii/_version.py b/opentaxii/_version.py index 42536e5c..170549cc 100644 --- a/opentaxii/_version.py +++ b/opentaxii/_version.py @@ -3,4 +3,4 @@ This module defines the package version for use in __init__.py and setup.py. """ -__version__ = '0.5.0' +__version__ = '0.6.0'