CVE-2021-37705 (published 2021-08-13T21:15Z)

OneFuzz is an open source self-hosted Fuzzing-As-A-Service platform. Starting with OneFuzz 2.12.0 or greater, an incomplete authorization check allows an authenticated user from any Azure Active Directory tenant to make authorized API calls to a vulnerable OneFuzz instance. To be vulnerable, a OneFuzz deployment must be both version 2.12.0 or greater and deployed with the non-default --multi_tenant_domain option. This can result in read/write access to private data such as software vulnerability and crash information, security testing tools and proprietary code and symbols. Via authorized API calls, this also enables tampering with existing data and unauthorized code execution on Azure compute resources. This issue is resolved starting in release 2.31.0, via the addition of application-level check of the bearer token's `issuer` against an administrator-configured allowlist. As a workaround users can restrict access to the tenant of a deployed OneFuzz instance < 2.31.0 by redeploying in the default configuration, which omits the `--multi_tenant_domain` option.

Severity: MEDIUM
CVSS v2 base score: 6.8 (AV:N/AC:M/Au:N/C:P/I:P/A:P)
CVSS v3 base score: 10.0, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-863
cwe_name: Incorrect Authorization
description: The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are incorrectly applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/microsoft/onefuzz
commit_details: repo_url: https://github.com/microsoft/onefuzz
author: bmc-msft
author_date: 2021-08-13 14:50:54-04:00
msg: Merge pull request from GHSA-q5vh-6whw-x745

* verify aad tenants, primarily needed in multi-tenant deployments

* add logging and fix trailing slash for issuer

* handle call_if* not supporting additional argument callbacks

* add logging

* include new datatype in webhook docs

* fix pytypes unit tests

Co-authored-by: Brian Caswell <bmc@shmoo.com>
repository: repo_url: https://github.com/microsoft/onefuzz
repo_name: microsoft/onefuzz
description: A self-hosted Fuzzing-As-A-Service platform
repo_language: C#
owner: microsoft
file_changes: filename: webhook_events.md
change_type: ModificationType.MODIFY
diff: @@ -641,7 +641,10 @@ Each event will be submitted via HTTP POST to the user provided URL.
         "admins": [
             "00000000-0000-0000-0000-000000000000"
         ],
-        "allow_pool_management": true
+        "allow_pool_management": true,
+        "allowed_aad_tenants": [
+            "00000000-0000-0000-0000-000000000000"
+        ]
     }
 }
 ```
@@ -665,8 +668,19 @@ Each event will be submitted via HTTP POST to the user provided URL.
                     "default": true,
                     "title": "Allow Pool Management",
                     "type": "boolean"
+                },
+                "allowed_aad_tenants": {
+                    "items": {
+                        "format": "uuid",
+                        "type": "string"
+                    },
+                    "title": "Allowed Aad Tenants",
+                    "type": "array"
                 }
             },
+            "required": [
+                "allowed_aad_tenants"
+            ],
             "title": "InstanceConfig",
             "type": "object"
         }
@@ -5599,8 +5613,19 @@ Each event will be submitted via HTTP POST to the user provided URL.
                     "default": true,
                     "title": "Allow Pool Management",
                     "type": "boolean"
+                },
+                "allowed_aad_tenants": {
+                    "items": {
+                        "format": "uuid",
+                        "type": "string"
+                    },
+                    "title": "Allowed Aad Tenants",
+                    "type": "array"
                 }
             },
+            "required": [
+                "allowed_aad_tenants"
+            ],
             "title": "InstanceConfig",
             "type": "object"
         },

programming_language: Markdown
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -14,11 +14,12 @@ from ..onefuzzlib.azure.creds import (
     get_instance_id,
     get_subscription,
 )
+from ..onefuzzlib.endpoint_authorization import call_if_user
 from ..onefuzzlib.request import ok
 from ..onefuzzlib.versions import versions
 
 
-def main(req: func.HttpRequest) -> func.HttpResponse:
+def get(req: func.HttpRequest) -> func.HttpResponse:
     response = ok(
         Info(
             resource_group=get_base_resource_group(),
@@ -32,3 +33,11 @@ def main(req: func.HttpRequest) -> func.HttpResponse:
     )
 
     return response
+
+
+def main(req: func.HttpRequest) -> func.HttpResponse:
+    methods = {"GET": get}
+    method = methods[req.method]
+    result = call_if_user(req, method)
+
+    return result

programming_language: Python
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -5,6 +5,8 @@
 
 import azure.functions as func
 
+from ..onefuzzlib.endpoint_authorization import call_if_user
+
 # This endpoint handles the signalr negotation
 # As we do not differentiate from clients at this time, we pass the Functions runtime
 # provided connection straight to the client
@@ -14,8 +16,19 @@ import azure.functions as func
 
 
 def main(req: func.HttpRequest, connectionInfoJson: str) -> func.HttpResponse:
-    return func.HttpResponse(
-        connectionInfoJson,
-        status_code=200,
-        headers={"Content-type": "application/json"},
-    )
+    # NOTE: this is a sub-method because the call_if* do not support callbacks with
+    # additional arguments at this time.  Once call_if* supports additional arguments,
+    # this should be made a generic function
+    def post(req: func.HttpRequest) -> func.HttpResponse:
+        return func.HttpResponse(
+            connectionInfoJson,
+            status_code=200,
+            headers={"Content-type": "application/json"},
+        )
+
+    methods = {"POST": post}
+    method = methods[req.method]
+
+    result = call_if_user(req, method)
+
+    return result

programming_language: Markdown
filename: config.py
change_type: ModificationType.MODIFY
diff: @@ -25,7 +25,7 @@ class InstanceConfig(BASE_CONFIG, ORMMixin):
     def fetch(cls) -> "InstanceConfig":
         entry = cls.get(get_instance_name())
         if entry is None:
-            entry = cls()
+            entry = cls(allowed_aad_tenants=[])
             entry.save()
         return entry
 

programming_language: Python
filename: user_credentials.py
change_type: ModificationType.MODIFY
diff: @@ -3,14 +3,18 @@
 # Copyright (c) Microsoft Corporation.
 # Licensed under the MIT License.
 
-from typing import Optional
+import logging
+from typing import List, Optional
 from uuid import UUID
 
 import azure.functions as func
 import jwt
+from memoization import cached
 from onefuzztypes.enums import ErrorCode
 from onefuzztypes.models import Error, Result, UserInfo
 
+from .config import InstanceConfig
+
 
 def get_bearer_token(request: func.HttpRequest) -> Optional[str]:
     auth: str = request.headers.get("Authorization", None)
@@ -39,6 +43,13 @@ def get_auth_token(request: func.HttpRequest) -> Optional[str]:
     return str(token_header)
 
 
+@cached(ttl=60)
+def get_allowed_tenants() -> List[str]:
+    config = InstanceConfig.fetch()
+    entries = [f"https://sts.windows.net/{x}/" for x in config.allowed_aad_tenants]
+    return entries
+
+
 def parse_jwt_token(request: func.HttpRequest) -> Result[UserInfo]:
     """Obtains the Access Token from the Authorization Header"""
     token_str = get_auth_token(request)
@@ -48,9 +59,20 @@ def parse_jwt_token(request: func.HttpRequest) -> Result[UserInfo]:
             errors=["unable to find authorization token"],
         )
 
-    # This token has already been verified by the azure authentication layer
+    # The JWT token has already been verified by the azure authentication layer,
+    # but we need to verify the tenant is as we expect.
     token = jwt.decode(token_str, options={"verify_signature": False})
 
+    if "iss" not in token:
+        return Error(
+            code=ErrorCode.INVALID_REQUEST, errors=["missing issuer from token"]
+        )
+
+    tenants = get_allowed_tenants()
+    if token["iss"] not in tenants:
+        logging.error("issuer not from allowed tenant: %s - %s", token["iss"], tenants)
+        return Error(code=ErrorCode.INVALID_REQUEST, errors=["unauthorized AAD issuer"])
+
     application_id = UUID(token["appid"]) if "appid" in token else None
     object_id = UUID(token["oid"]) if "oid" in token else None
     upn = token.get("upn")

programming_language: Python
filename: test_auth_check.py
change_type: ModificationType.MODIFY
diff: @@ -5,7 +5,7 @@
 
 import os
 import unittest
-from uuid import uuid4
+from uuid import UUID, uuid4
 
 from onefuzztypes.models import UserInfo
 
@@ -25,29 +25,41 @@ class TestAdmin(unittest.TestCase):
         user2 = uuid4()
 
         # no admins set
-        self.assertTrue(can_modify_config_impl(InstanceConfig(), UserInfo()))
+        self.assertTrue(
+            can_modify_config_impl(
+                InstanceConfig(allowed_aad_tenants=[UUID(int=0)]), UserInfo()
+            )
+        )
 
         # with oid, but no admin
         self.assertTrue(
-            can_modify_config_impl(InstanceConfig(), UserInfo(object_id=user1))
+            can_modify_config_impl(
+                InstanceConfig(allowed_aad_tenants=[UUID(int=0)]),
+                UserInfo(object_id=user1),
+            )
         )
 
         # is admin
         self.assertTrue(
             can_modify_config_impl(
-                InstanceConfig(admins=[user1]), UserInfo(object_id=user1)
+                InstanceConfig(allowed_aad_tenants=[UUID(int=0)], admins=[user1]),
+                UserInfo(object_id=user1),
             )
         )
 
         # no user oid set
         self.assertFalse(
-            can_modify_config_impl(InstanceConfig(admins=[user1]), UserInfo())
+            can_modify_config_impl(
+                InstanceConfig(allowed_aad_tenants=[UUID(int=0)], admins=[user1]),
+                UserInfo(),
+            )
         )
 
         # not an admin
         self.assertFalse(
             can_modify_config_impl(
-                InstanceConfig(admins=[user1]), UserInfo(object_id=user2)
+                InstanceConfig(allowed_aad_tenants=[UUID(int=0)], admins=[user1]),
+                UserInfo(object_id=user2),
             )
         )
 
@@ -58,21 +70,31 @@ class TestAdmin(unittest.TestCase):
         # by default, any can modify
         self.assertIsNone(
             check_can_manage_pools_impl(
-                InstanceConfig(allow_pool_management=True), UserInfo()
+                InstanceConfig(
+                    allowed_aad_tenants=[UUID(int=0)], allow_pool_management=True
+                ),
+                UserInfo(),
             )
         )
 
         # with oid, but no admin
         self.assertIsNone(
             check_can_manage_pools_impl(
-                InstanceConfig(allow_pool_management=True), UserInfo(object_id=user1)
+                InstanceConfig(
+                    allowed_aad_tenants=[UUID(int=0)], allow_pool_management=True
+                ),
+                UserInfo(object_id=user1),
             )
         )
 
         # is admin
         self.assertIsNone(
             check_can_manage_pools_impl(
-                InstanceConfig(allow_pool_management=False, admins=[user1]),
+                InstanceConfig(
+                    allowed_aad_tenants=[UUID(int=0)],
+                    allow_pool_management=False,
+                    admins=[user1],
+                ),
                 UserInfo(object_id=user1),
             )
         )
@@ -80,14 +102,23 @@ class TestAdmin(unittest.TestCase):
         # no user oid set
         self.assertIsNotNone(
             check_can_manage_pools_impl(
-                InstanceConfig(allow_pool_management=False, admins=[user1]), UserInfo()
+                InstanceConfig(
+                    allowed_aad_tenants=[UUID(int=0)],
+                    allow_pool_management=False,
+                    admins=[user1],
+                ),
+                UserInfo(),
             )
         )
 
         # not an admin
         self.assertIsNotNone(
             check_can_manage_pools_impl(
-                InstanceConfig(allow_pool_management=False, admins=[user1]),
+                InstanceConfig(
+                    allowed_aad_tenants=[UUID(int=0)],
+                    allow_pool_management=False,
+                    admins=[user1],
+                ),
                 UserInfo(object_id=user2),
             )
         )

programming_language: Python
filename: azuredeploy.json
change_type: ModificationType.MODIFY
diff: @@ -821,6 +821,10 @@
         "scaleset-identity": {
             "type": "string",
             "value": "[variables('scaleset_identity')]"
+        },
+        "tenant_id": {
+            "type": "string",
+            "value": "[subscription().tenantId]"
         }
     }
 }

programming_language: Jupyter Notebook
filename: deploy.py
change_type: ModificationType.MODIFY
diff: @@ -72,7 +72,7 @@ from registration import (
     set_app_audience,
     update_pool_registration,
 )
-from set_admins import update_admins
+from set_admins import update_admins, update_allowed_aad_tenants
 
 # Found by manually assigning the User.Read permission to application
 # registration in the admin portal. The values are in the manifest under
@@ -130,7 +130,8 @@ class Client:
         multi_tenant_domain: str,
         upgrade: bool,
         subscription_id: Optional[str],
-        admins: List[UUID]
+        admins: List[UUID],
+        allowed_aad_tenants: List[UUID],
     ):
         self.subscription_id = subscription_id
         self.resource_group = resource_group
@@ -161,6 +162,7 @@ class Client:
         self.export_appinsights = export_appinsights
         self.log_service_principal = log_service_principal
         self.admins = admins
+        self.allowed_aad_tenants = allowed_aad_tenants
 
         machine = platform.machine()
         system = platform.system()
@@ -560,13 +562,20 @@ class Client:
         table_service = TableService(account_name=name, account_key=key)
         migrate(table_service, self.migrations)
 
-    def set_admins(self) -> None:
+    def set_instance_config(self) -> None:
         name = self.results["deploy"]["func-name"]["value"]
         key = self.results["deploy"]["func-key"]["value"]
+        tenant = UUID(self.results["deploy"]["tenant_id"]["value"])
         table_service = TableService(account_name=name, account_key=key)
+
         if self.admins:
             update_admins(table_service, self.application_name, self.admins)
 
+        tenants = self.allowed_aad_tenants
+        if tenant not in tenants:
+            tenants.append(tenant)
+        update_allowed_aad_tenants(table_service, self.application_name, tenants)
+
     def create_queues(self) -> None:
         logger.info("creating eventgrid destination queue")
 
@@ -926,7 +935,7 @@ def main() -> None:
 
     full_deployment_states = rbac_only_states + [
         ("apply_migrations", Client.apply_migrations),
-        ("set_admins", Client.set_admins),
+        ("set_instance_config", Client.set_instance_config),
         ("queues", Client.create_queues),
         ("eventgrid", Client.create_eventgrid),
         ("tools", Client.upload_tools),
@@ -1038,6 +1047,12 @@ def main() -> None:
         nargs="*",
         help="set the list of administrators (by OID in AAD)",
     )
+    parser.add_argument(
+        "--allowed_aad_tenants",
+        type=UUID,
+        nargs="*",
+        help="Set additional AAD tenants beyond the tenant the app is deployed in",
+    )
 
     args = parser.parse_args()
 
@@ -1066,6 +1081,7 @@ def main() -> None:
         upgrade=args.upgrade,
         subscription_id=args.subscription_id,
         admins=args.set_admins,
+        allowed_aad_tenants=args.allowed_aad_tenants or [],
     )
     if args.verbose:
         level = logging.DEBUG

programming_language: Python
filename: set_admins.py
change_type: ModificationType.MODIFY
diff: @@ -20,6 +20,21 @@ def create_if_missing(table_service: TableService) -> None:
         table_service.create_table(TABLE_NAME)
 
 
+def update_allowed_aad_tenants(
+    table_service: TableService, resource_group: str, tenants: List[UUID]
+) -> None:
+    create_if_missing(table_service)
+    as_str = [str(x) for x in tenants]
+    table_service.insert_or_merge_entity(
+        TABLE_NAME,
+        {
+            "PartitionKey": resource_group,
+            "RowKey": resource_group,
+            "allowed_aad_tenants": json.dumps(as_str),
+        },
+    )
+
+
 def update_admins(
     table_service: TableService, resource_group: str, admins: List[UUID]
 ) -> None:
@@ -43,7 +58,8 @@ def main() -> None:
     parser = argparse.ArgumentParser(formatter_class=formatter)
     parser.add_argument("resource_group")
     parser.add_argument("storage_account")
-    parser.add_argument("admins", type=UUID, nargs="*")
+    parser.add_argument("--admins", type=UUID, nargs="*")
+    parser.add_argument("--allowed_aad_tenants", type=UUID, nargs="*")
     args = parser.parse_args()
 
     client = get_client_from_cli_profile(StorageManagementClient)
@@ -53,7 +69,12 @@ def main() -> None:
     table_service = TableService(
         account_name=args.storage_account, account_key=storage_keys.keys[0].value
     )
-    update_admins(table_service, args.resource_group, args.admins)
+    if args.admins:
+        update_admins(table_service, args.resource_group, args.admins)
+    if args.allowed_aad_tenants:
+        update_allowed_aad_tenants(
+            table_service, args.resource_group, args.allowed_aad_tenants
+        )
 
 
 if __name__ == "__main__":

programming_language: Python
filename: generate-docs.py
change_type: ModificationType.MODIFY
diff: @@ -258,7 +258,11 @@ def main() -> None:
         EventFileAdded(container=Container("container-name"), filename="example.txt"),
         EventNodeHeartbeat(machine_id=UUID(int=0), pool_name=PoolName("example")),
         EventTaskHeartbeat(task_id=UUID(int=0), job_id=UUID(int=0), config=task_config),
-        EventInstanceConfigUpdated(config=InstanceConfig(admins=[UUID(int=0)])),
+        EventInstanceConfigUpdated(
+            config=InstanceConfig(
+                admins=[UUID(int=0)], allowed_aad_tenants=[UUID(int=0)]
+            )
+        ),
     ]
 
     # works around `mypy` not handling that Union has `__args__`

programming_language: Python
filename: models.py
change_type: ModificationType.MODIFY
diff: @@ -802,6 +802,8 @@ class InstanceConfig(BaseModel):
     # if set, only admins can manage pools or scalesets
     allow_pool_management: bool = Field(default=True)
 
+    allowed_aad_tenants: List[UUID]
+
     def update(self, config: "InstanceConfig") -> None:
         for field in config.__fields__:
             # If no admins are set, then ignore setting admins
@@ -817,5 +819,16 @@ class InstanceConfig(BaseModel):
             raise ValueError("admins must be None or contain at least one UUID")
         return value
 
+    # At the moment, this only checks allowed_aad_tenants, however adding
+    # support for 3rd party JWT validation is anticipated in a future release.
+    @root_validator()
+    def check_instance_config(cls, values: Any) -> Any:
+        if "allowed_aad_tenants" not in values:
+            raise ValueError("missing allowed_aad_tenants")
+
+        if not len(values["allowed_aad_tenants"]):
+            raise ValueError("allowed_aad_tenants must not be empty")
+        return values
+
 
 _check_hotfix()

programming_language: Python
filename: test_instance_config_update.py
change_type: ModificationType.MODIFY
diff: @@ -11,9 +11,13 @@ from onefuzztypes.models import InstanceConfig
 
 class TestInstanceConfig(unittest.TestCase):
     def test_with_admins(self) -> None:
-        no_admins = InstanceConfig(admins=None)
-        with_admins = InstanceConfig(admins=[UUID(int=0)])
-        with_admins_2 = InstanceConfig(admins=[UUID(int=1)])
+        no_admins = InstanceConfig(admins=None, allowed_aad_tenants=[UUID(int=0)])
+        with_admins = InstanceConfig(
+            admins=[UUID(int=0)], allowed_aad_tenants=[UUID(int=0)]
+        )
+        with_admins_2 = InstanceConfig(
+            admins=[UUID(int=1)], allowed_aad_tenants=[UUID(int=0)]
+        )
 
         no_admins.update(with_admins)
         self.assertEqual(no_admins.admins, None)

programming_language: Python