{"record":{"id":"e0dcb71d0da58c00","repo":"nextcloud/server","slug":"untrusted-media-source","errorCode":null,"errorMessage":"Untrusted media source","messagePattern":"Untrusted media source","errorType":"exception","errorClass":"OCSBadRequestException","httpStatus":400,"severity":"warning","filePath":"apps/appstore/lib/Controller/DiscoverController.php","lineNumber":127,"sourceCode":"\t\tif ($file === false) {\n\t\t\t$user = $session->getUser();\n\t\t\t// this route is not public thus we can assume a user is logged-in\n\t\t\tassert($user !== null);\n\t\t\t// Register a user request to throttle fetching external data\n\t\t\t// this will prevent using the server for DoS of other systems.\n\t\t\t$limiter->registerUserRequest(\n\t\t\t\t'settings-discover-media',\n\t\t\t\t// allow up to 24 media requests per hour\n\t\t\t\t// this should be a sane default when a completely new section is loaded\n\t\t\t\t// keep in mind browsers request all files from a source-set\n\t\t\t\t24,\n\t\t\t\t60 * 60,\n\t\t\t\t$user,\n\t\t\t);\n\n\t\t\tif (!$this->checkCanDownloadMedia($fileName)) {\n\t\t\t\t$this->logger->warning('Tried to load media files for app discover section from untrusted source');\n\t\t\t\tthrow new OCSBadRequestException('Untrusted media source');\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\t$client = $this->clientService->newClient();\n\t\t\t\t$fileResponse = $client->get($fileName);\n\t\t\t\t$contentType = $fileResponse->getHeader('Content-Type');\n\t\t\t\t$extension = $info['extension'] ?? '';\n\t\t\t\t$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());\n\t\t\t} catch (\\Throwable $e) {\n\t\t\t\t$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);\n\t\t\t\tthrow new OCSNotFoundException('Media file not found');\n\t\t\t}\n\t\t} else {\n\t\t\t// File was found so we can get the content type from the file name\n\t\t\t$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');\n\t\t}\n\n\t\t$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);","sourceCodeStart":109,"sourceCodeEnd":145,"githubUrl":"https://github.com/nextcloud/server/blob/ecdeb153ffdf227235c9a7e2d13dbe0f9c817bc3/apps/appstore/lib/Controller/DiscoverController.php#L109-L145","documentation":"OCSBadRequestException (HTTP 400) thrown by DiscoverController::mediaImage when checkCanDownloadMedia($fileName) returns false. The discover-media proxy only fetches remote media from trusted sources, so a media URL outside the allowlist is rejected before any HTTP request is made — this is a deliberate SSRF guard, logged as a warning ('Tried to load media files ... from untrusted source').","triggerScenarios":"GET the discover media endpoint with a media_src whose host is not in the trusted set: an app release advertising screenshots on a custom/renamed CDN, a self-hosted or third-party app store whose domain is not allowlisted, or a manipulated request passing an arbitrary URL as fileName.","commonSituations":"Third-party appstore deployments whose media hosts are unknown to the server; app developers pointing media_src at their own servers; Nextcloud version changes tightening the allowlist after an upgrade.","solutions":["Use media URLs that come from the official app store data (they are inside the trusted source list)","If you operate a custom app store, make sure its media domain is included in the server's trusted source configuration for discover media","As an app publisher, host release media on the appstore-provided locations rather than arbitrary hosts","Do not attempt to use this endpoint as a generic image proxy — by design it will 400"],"exampleFix":null,"handlingStrategy":"validation","validationCode":"// Sender/publisher side: only reference trusted appstore media hosts\nconst TRUSTED = ['cdn.nextcloud.com', 'apps.nextcloud.com']\nconst host = new URL(mediaSrc).hostname\nif (!TRUSTED.includes(host)) {\n\t// do not use this URL as media_src — the server will reject it with 'Untrusted media source'\n}","typeGuard":null,"tryCatchPattern":"try {\n\tconst blob = await axios.get(mediaProxyUrl, { params: { fileName: mediaSrc } })\n} catch (e) {\n\tif (e?.response?.status === 400) {\n\t\t// 'Untrusted media source' — SSRF guard: use a trusted appstore URL or adjust the server's trusted-source config\n\t} else throw e\n}","preventionTips":["Never use the discover media endpoint as a generic proxy — it validates sources by design","App publishers: host release media where the appstore serves it from","Custom app store operators: ensure your media domain is in the server's trusted sources"],"tags":["appstore","discover","security","ssrf","url-validation","php"],"backgroundTag":"ssrf-guard-rejected-url","analyzedSha":"ecdeb153ffdf227235c9a7e2d13dbe0f9c817bc3","analyzedAt":"2026-08-17T01:36:13.386Z","schemaVersion":2},"datasetVersion":"2026-08-17T04:17:16.089Z"}