{"id":"PYSEC-2026-2554","summary":"Kolibri has Unauthenticated Server-Side Request Forgery (SSRF) in RemoteFacilityUserViewset","details":"## Summary\n\nSeveral Kolibri API endpoints accept an unvalidated `baseurl` parameter and fetch attacker-controlled URLs from the Kolibri server, reflecting the response body back to the caller. The original report identified two endpoints on the `RemoteFacilityUser*` viewsets; remediation review found two further reflection points on the same pattern. The GET endpoint was unauthenticated.\n\n## Affected endpoints\n\nReported:\n\n- `GET /api/auth/remotefacilityuser` → `RemoteFacilityUserViewset` (`kolibri/core/auth/api.py:1570`). No authentication required.\n- `POST /api/auth/remotefacilityauthenticateduserinfo` → `RemoteFacilityUserAuthenticatedViewset` (`kolibri/core/auth/api.py:1594`). Authentication is checked against the *remote* server rather than the local Kolibri.\n\nFound during remediation:\n\n- `POST /api/public/setupwizard/loddata` → setup wizard's remote-signup proxy (`kolibri/plugins/setup_wizard/api.py`). Reachable on unprovisioned devices.\n- `GET /api/public/networklocation/\u003cid\u003e/facilities/` → `NetworkLocationFacilitiesView` (`kolibri/core/discovery/api.py`). Authenticated but with the same `Response(remote_payload)` pattern.\n\n## Root cause\n\nTwo compounding issues:\n\n1. **Response reflection** — these endpoints returned the remote server's JSON body more or less verbatim to the caller (`Response(response.json())`, `Response(facility_info[\"users\"])`, etc.).\n2. **No restriction on the remote target** — `baseurl` was validated only by `URLValidator(schemes=[\"http\", \"https\"])`. `NetworkClient.build_for_address()` would connect to any host with a valid Kolibri-shaped `/api/public/info/` response, and `requests` followed 30x redirects by default, so a hostile peer could pivot the fetch to an arbitrary host (cloud metadata, internal services) before reflection.\n\n## Two reflection vectors\n\n**GET vector (`RemoteFacilityUserViewset`):**\nThe viewset fetched `\u003cbaseurl\u003e/api/public/facilitysearchuser/` and returned `Response(response.json())`. An attacker-controlled `baseurl` returned a 302 to an arbitrary internal URL; `requests` followed the redirect, and the redirected response body was returned to the attacker.\n\n**POST vector (`RemoteFacilityUserAuthenticatedViewset`):**\n`get_remote_users_info()` fetched `\u003cbaseurl\u003e/api/public/facilityuser/` with Basic Auth and the viewset returned `Response(facility_info[\"users\"])`. A malicious `baseurl` returned crafted user-shaped JSON; arbitrary smuggled fields were reflected back to the caller. The setup wizard and `NetworkLocationFacilitiesView` endpoints had the same shape on different remote URLs.\n\n## Reproduction\n\nThe vulnerability can be reproduced by pointing `baseurl` at an attacker-controlled HTTP server that:\n\n1. Responds to `GET /api/public/info/` with a valid Kolibri info payload (so `NetworkClient.build_for_address()` succeeds).\n2. **GET vector:** responds to `GET /api/public/facilitysearchuser/` with a 302 redirect to the target URL. The redirected response body is reflected via `Response(response.json())`.\n3. **POST vector:** responds to the relevant remote URL with crafted JSON containing additional fields. The full JSON is reflected.\n\nA working PoC has been retained internally and is not published with this advisory.\n\n## Demonstrated impact (pre-fix)\n\n- **Unauthenticated outbound requests from the Kolibri server** to any HTTP(S) URL the attacker chose (GET endpoint only; the others required auth or an unprovisioned device).\n- **Reflected data exfiltration** for any HTTP endpoint that responded to a plain `GET` with JSON and no special request headers.\n- **Cloud metadata reachability** was realistic but service-specific:\n  - AWS IMDSv1 — reachable\n  - DigitalOcean (`/metadata/v1.json`) — reachable\n  - GCP, Azure, AWS IMDSv2 — *not* reachable via this vector (require `Metadata-Flavor` / `Metadata` / token headers that the attacker could not inject)\n- **Reachability of internal HTTP services** on the same network as the Kolibri server, with their JSON responses returned to the attacker.\n\n## Not demonstrated\n\nThe earlier draft asserted port scanning via a timing oracle and generic \"internal network mapping.\" The reflection vector reads response bodies directly when the target speaks JSON; timing-based scanning of arbitrary TCP services was not demonstrated and is not the headline risk.\n\n## Mitigation\n\nFour layers of defence:\n\n1. **Response sanitisation.** Each affected endpoint now coerces the remote response to a documented shape before returning it. Smuggled fields are dropped.\n2. **Authentication.** The previously-open `RemoteFacilityUser*` endpoints now require an authenticated caller (or an unprovisioned device, for setup-wizard flows).\n3. **Cross-host redirect blocking.** Remote-fetch HTTP sessions refuse 30x responses that point to a different hostname. Same-host redirects still work.\n4. **Peer allowlist.** Endpoints that accept a caller-supplied `baseurl` resolve it only to peers Kolibri already knows about, rather than connecting to arbitrary hosts. Discovery and CLI flows that legitimately need to probe new addresses use a separate code path.\n\n## Credit\n\nInitial report and identification of the `RemoteFacilityUser*` viewsets by @beraoudabdelkhalek. Reflection-based PoC, additional vector identification, and remediation by the Kolibri maintainers.\n\n\n\u003cdetails\u003e\u003csummary\u003eOriginal report by @beraoudabdelkhalek\u003c/summary\u003e\n\n### Summary\nThe `RemoteFacilityUserViewset` API endpoint (`/api/auth/remotefacilityuser`) has no authentication or permission checks and accepts a user-controlled `baseurl` parameter. This parameter is passed directly to `NetworkClient.build_for_address()` which makes server-side HTTP requests to the attacker-specified URL. An unauthenticated attacker can force the Kolibri server to reach out to arbitrary internal hosts, port-scan internal networks, and access cloud metadata endpoints.\n\n### Details\n\nThis is mainly due to the following issues:\n\n**1. Missing authentication on the API endpoint**\n\nFile: `kolibri/core/auth/api.py`, line ~1553\n\n```python\nclass RemoteFacilityUserViewset(views.APIView):  # No permission_classes → AllowAny\n    def get(self, request):\n        baseurl = request.query_params.get(\"baseurl\", \"\")\n        validator(baseurl)  # Only checks URL format (http/https scheme + valid hostname)\n        client = NetworkClient.build_for_address(baseurl)\n        response = client.get(url, params={\"facility\": facility, \"search\": username})\n```\n\nNo `permission_classes` attribute is defined, and `DEFAULT_PERMISSION_CLASSES` is not set in the DRF configuration, so the endpoint defaults to `AllowAny` , accepting requests with zero authentication.\n\nSimilarly, `RemoteFacilityUserAuthenticatedViewset` (line ~1577, POST endpoint) also has no `permission_classes`, though it currently checks permissions via a different mechanism. The initial `build_for_address()` call still fires before that check.\n\n**2. Weak URL validation**\n\nFile: `kolibri/utils/urls.py`, line 1-7\n\n```python\nfrom django.core.validators import URLValidator\nvalidator = URLValidator(schemes=[\"http\", \"https\"])\n```\n\nThe only validation is that the URL has an http or https scheme and a valid hostname. There is no block on:\n- RFC 1918 private IPs (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)\n- Loopback addresses (127.0.0.0/8, ::1)\n- Link-local addresses (169.254.0.0/16, including AWS/GCP/Azure metadata endpoints)\n- IPv6 equivalents of any of the above\n\n### PoC\n**Prerequisites**: A listener on a host reachable by the Kolibri server (e.g., `nc -lvp 1337`) the listener can be local or remote.\n\nAgainst a local Docker deployment (validated against Kolibri 0.19.3):\n\n```bash\n# Trigger the SSRF no auth headers needed\ncurl \"http://localhost:8080/api/auth/remotefacilityuser?baseurl=http://172.17.0.1:1337&username=test&facility=\u003cfacility_id\u003e\"\n```\n\nThe Kolibri server makes an outbound HTTP request to the attacker's listener:\n\n```\nGET /api/public/info/?v=3 HTTP/1.1\nHost: 172.17.0.1:1337\nUser-Agent: Kolibri/0.19.3 python-requests/2.27.1\nAccept-Encoding: gzip, deflate\nAccept: */*\nConnection: keep-alive\n```\n\nTesters have also confirmed the issue against live deployments of Kolibri.\n\n### Impact\nUnauthenticated SSRF : any attacker who can reach the Kolibri server can make it issue HTTP requests to arbitrary hosts, with no credentials needed\nInternal network scanning : the built-in port scanning behavior (5+ ports per HTTP target, 24+ connection attempts per request) allows mapping internal networks through the timing oracle\nCloud metadata access : if Kolibri runs on a cloud VM (AWS EC2, GCP, Azure), the attacker can reach 169.254.169.254 and potentially exfiltrate IAM credentials and instance metadata\nInternal service discovery : other Kolibri instances or internal services on the network can be discovered and their API responses read by the attacker\nBlind SSRF via POST endpoint : RemoteFacilityUserAuthenticatedViewset returns 403 to the attacker but still makes the outbound request before the permission check\n\n\u003c/details\u003e","aliases":["CVE-2026-48053","GHSA-4mj9-pf4r-cqrc"],"modified":"2026-07-13T16:31:48.605331962Z","published":"2026-07-13T15:46:16.729758Z","references":[{"type":"WEB","url":"https://github.com/learningequality/kolibri/security/advisories/GHSA-4mj9-pf4r-cqrc"},{"type":"PACKAGE","url":"https://github.com/learningequality/kolibri"},{"type":"WEB","url":"https://github.com/learningequality/kolibri/releases/tag/v0.19.4"},{"type":"PACKAGE","url":"https://pypi.org/project/kolibri"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-4mj9-pf4r-cqrc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-48053"}],"affected":[{"package":{"name":"kolibri","ecosystem":"PyPI","purl":"pkg:pypi/kolibri"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.19.4"}]}],"versions":["0.0.1.dev20160531112859","0.1.1rc2","0.1.1rc3","0.10.0","0.10.1","0.10.2","0.10.3","0.10.3b2","0.10.3b3","0.11.0","0.11.0b2","0.11.1","0.12.0","0.12.0b5","0.12.0b6","0.12.0b7","0.12.0b8","0.12.0b9","0.12.1","0.12.10b3","0.12.2","0.12.3","0.12.3b1","0.12.4","0.12.4b2","0.12.4b3","0.12.4b5","0.12.4b6","0.12.4b7","0.12.4b8","0.12.5","0.12.5b1","0.12.5b2","0.12.6","0.12.6b1","0.12.6b2","0.12.6rc1","0.12.7","0.12.8","0.12.8rc1","0.12.9","0.12.9rc1","0.13.0","0.13.0b2","0.13.0b3","0.13.0b4","0.13.0b5","0.13.0b6","0.13.0b7","0.13.0b8","0.13.1","0.13.1b1","0.13.1b2","0.13.2","0.13.2b1","0.13.3","0.13.3b1","0.13.3rc1","0.14.0","0.14.0b10","0.14.0b3","0.14.0rc1","0.14.1","0.14.2","0.14.3","0.14.4","0.14.4rc1","0.14.5","0.14.6","0.14.7","0.14.8a1","0.14.8a2","0.14.8a4","0.14.8a5","0.14.8a6","0.14.8a7","0.14.8a8","0.15.0","0.15.0b5","0.15.1","0.15.10","0.15.11","0.15.12","0.15.1b1","0.15.2","0.15.3","0.15.4","0.15.5","0.15.6","0.15.7","0.15.8","0.15.9","0.16.0","0.16.1","0.16.2","0.17.0","0.17.1","0.17.2","0.17.3","0.17.4","0.17.5","0.18.0","0.18.1","0.18.2","0.18.3","0.18.4","0.19.0","0.19.1","0.19.2","0.19.3","0.2.0a0","0.2.0a2","0.2.1","0.4.4","0.5.3","0.6.1","0.7.0","0.7.1","0.7.2","0.7.2b2","0.8.0","0.8.0b3","0.8.0b4","0.9.0","0.9.1","0.9.2"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/kolibri/PYSEC-2026-2554.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N"}]}