GHSA-x287-5c68-36wp: Medium severity pip/openwisp-ipam vulnerability
Summary
OpenWISP IPAM is multi-tenant: every Subnet belongs to an organization, and API access is scoped to the organizations a user belongs to. The CSV export endpoint, ExportSubnetView, omits the organization-membership check that its import sibling performs, and loads the subnet by primary key with no organization filter. An authenticated user who is a member of one organization can therefore export a subnet belonging to another organization — its name, CIDR, organization slug and every IP address in it — by issuing an export request for that subnet's id.
Details
ImportSubnetView.post() authorizes first, by calling assertorganizationpermissions():
python openwispipam/api/views.py (ImportSubnetView) def post(self, request, args, kwargs): self.assertorganizationpermissions(request) # <-- org-membership check file = request.FILES["csvfile"] ... self.subnetmodel().importcsv(file) python openwispipam/api/utils.py:16 (AuthorizeCSVImport) def assertorganizationpermissions(self, request): if request.user.issuperuser: return # ... otherwise verifies the user belongs to the target organization
ExportSubnetView.post() performs no such check. It is declared with IpAddressOrgMixin (whose organization scoping lives in getqueryset()), but its overridden post() never calls getqueryset() or assertorganizationpermissions() — it goes straight to exportcsv():
python openwispipam/api/views.py:246 class ExportSubnetView(ProtectedAPIMixin, IpAddressOrgMixin, CreateAPIView): subnetmodel = Subnet def post(self, request, args, kwargs): response = HttpResponse(contenttype="text/csv") response["Content-Disposition"] = 'attachment; filename="ipaddress.csv"' writer = csv.writer(response) self.subnetmodel().exportcsv(kwargs["subnetid"], writer) # no org check reached return response
exportcsv() resolves the subnet by primary key with no organization filter and writes its full contents:
python openwispipam/base/models.py:242 def exportcsv(self, subnetid, writer): ipaddressmodel = loadmodel("openwispipam", "IpAddress") subnet = loadmodel("openwispipam", "Subnet").objects.get(pk=subnetid) # any org's subnet # ... writes subnet name, CIDR, organization slug, and every IpAddress (address, description, …)
The subnetid is a random UUIDField (migrations/0001initial.py: models.UUIDField(default=uuid.uuid4)), so an attacker must obtain the target subnet's id rather than enumerate it — hence Attack Complexity: High. This is a genuine missing-authorization flaw regardless: the import/export asymmetry shows the check was intended, and subnet ids routinely appear in URLs, logs, shared CSV exports and support tickets, from which a cross-organization id can leak.
The same single-object pattern (resolving the subnet by subnetid outside the org-scoped queryset) also appears in AvailableIpView.get() and RequestIPView.post() (the latter additionally writes an IP into the target subnet); these are worth reviewing alongside the export fix.
Proof of concept
Prerequisites: an OpenWISP IPAM instance with two organizations OrgA and OrgB; a normal (non-superuser) user who is a member of OrgB only; a subnet in OrgA whose id S is known to the attacker (e.g. leaked via a URL/log/shared export).
1. As the OrgB user, authenticate to the API. 2. POST /api/v1/subnet/{S}/export/ (OrgA's subnet id). 3. Result: a CSV download containing OrgA's subnet name, CIDR, organization slug and every IP address in it — even though the user belongs only to OrgB. 4. Control (proves it's an oversight): the corresponding import endpoint POST /api/v1/import-subnet/ with OrgA data is rejected for the same user by assertorganizationpermissions().
(Reported from a first-hand source review of the current master: the missing check in ExportSubnetView.post, the guarded ImportSubnetView.post sibling, the un-filtered Subnet.objects.get(pk=…) in exportcsv, and the UUIDField primary key were each verified by hand. The fix is a one-line authorization call, so a maintainer can confirm trivially with two organizations.)
Impact
A member of one organization can read the complete contents of another organization's subnet — its IP allocation inventory (addresses, descriptions, organization slug, CIDR). In a WISP / network-management deployment this discloses another tenant's network topology and host inventory. Exploitation requires obtaining the target subnet's UUID (AC:H), but the authorization check is missing outright. (RequestIPView shares the pattern and additionally allows writing an IP into another organization's subnet — a separate integrity concern for the maintainer to confirm.)
Remediation
Add the organization-membership check to ExportSubnetView.post() — call self.assertorganizationpermissions(request) (as ImportSubnetView does), or resolve the subnet through the org-scoped getqueryset() / getobject() and return 404 when it isn't in the caller's organizations. Apply the same to AvailableIpView and RequestIPView.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/openwisp-ipamto a version that resolves this vulnerability.Fixed in 1.2.1 - Configuration
In `ExportSubnetView.post()`, add an organization-membership check by calling `self.assert_organization_permissions(request)` (mirroring `ImportSubnetView.post()`). This ensures the subnet loaded/exported belongs to one of the caller's organizations; otherwise resolve via the org-scoped `get_queryset()`/`get_object()` and return 404.
OpenWISP IPAM API (ExportSubnetView.post) organization-membership authorization check = enabled - Configuration
In `export_csv(subnet_id, writer)`, stop resolving the subnet via `Subnet.objects.get(pk=subnet_id)` without an organization filter. Instead resolve the subnet through the org-scoped queryset/get_object so cross-organization UUIDs return 404.
OpenWISP IPAM API (ExportSubnetView.export_csv) subnet lookup scope = organization-scoped - Configuration
Review and apply the same single-object pattern used in the export fix: ensure `AvailableIpView.get()` resolves the target subnet/IP data via the org-scoped queryset or by calling `assert_organization_permissions()` so callers cannot access another organization's inventory.
OpenWISP IPAM API (AvailableIpView.get) organization-membership enforcement in post/get = enabled - Configuration
Review and apply the same single-object pattern used in the export fix: ensure `RequestIPView.post()` authorizes/resolves the target subnet/IP write via the org-scoped queryset or by calling `assert_organization_permissions()` so callers cannot write IPs into another organization's subnet.
OpenWISP IPAM API (RequestIPView.post) organization-membership enforcement in post/get = enabled
Event History
Frequently Asked Questions
What access and information does an attacker need to retrieve another tenant's subnet data?
The attacker must be authenticated and belong to at least one organization. They also need the primary-key ID of the target subnet and can submit an export request for that ID; they do not need membership in the target organization.
What information can be disclosed through the unauthorized export?
The export can disclose the target subnet's name, CIDR, organization slug, and every IP address contained in that subnet.