{"id":"CVE-2025-69206","aliases":["GHSA-vvxf-wj5w-6gj5"],"url":"https://o3.security/vulnerability/CVE-2025-69206","summary":"Hemmelig has SSRF Filter bypass in Secret Request functionality","details":"### Summary\nA Server-Side Request Forgery (SSRF) filter bypass vulnerability exists in the webhook URL validation of the Secret Requests feature. The application attempts to block internal/private IP addresses but can be bypassed using DNS rebinding (e.g., `localtest.me` which resolves to `127.0.0.1`) or open redirect services (e.g., `httpbin.org/redirect-to`). This allows an authenticated user to make the server initiate HTTP requests to internal network resources.\n\n### Details\nThe vulnerability exists in the `isPublicUrl` function located in `/api/lib/utils.ts`. The function validates webhook URLs against a blocklist of private IP patterns:\n\n```typescript\nexport const isPublicUrl = (url: string): boolean => {\n    const parsed = new URL(url);\n    const hostname = parsed.hostname.toLowerCase();\n    \n    const blockedPatterns = [\n        /^localhost$/,\n        /^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/,\n        /^192\\.168\\.\\d{1,3}\\.\\d{1,3}$/,\n        // ... other patterns\n    ];\n    \n    return !blockedPatterns.some((pattern) => pattern.test(hostname));\n};\n```\n\n**The validation is flawed because:**\n\n1. **DNS Rebinding Bypass**: It only checks the hostname string, not the resolved IP address. Domains like `localtest.me` pass validation (not matching any blocked pattern) but resolve to `127.0.0.1`.\n\n2. **Open Redirect Bypass**: External URLs like `httpbin.org/redirect-to?url=http://127.0.0.1` pass validation since `httpbin.org` is a public domain. When the server follows the redirect, it connects to the internal address.\n\n### PoC\nOptional: On the container that runs Hemmelig application, host a temporary port with the following command: \n```\nnode -e \"require('http').createServer((req,res)=>{console.log(req.method,req.url,req.headers);res.end('ok')}).listen(8080,()=>console.log('Listening on 8080'))\"\n```\n1. Log in as an user\n2. Switch to `Secret Requests` tab and create a new request\n3. When inside the request dialog, there are 2 possible payloads that can be used on the `Webhook URL` input to bypass SSRF\n```\n1. Using domain redirect: http://localtest.me:PORT\n2. Using httpbin to perform a redirect: httpbin.org/redirect-to?url=http://127.0.0.1:PORT\n```\n4. Open a new browser/tab and confirm the request by creating a secret. Upon clicking save, the port we hosted we receive a request. \n<img width=\"795\" height=\"310\" alt=\"image\" src=\"https://github.com/user-attachments/assets/95d559e5-ead2-4b5d-8e53-9ddec3416953\" />\n\nOtherwise, if the port doesn't exist, a similar error in the logs can be found:\n```\nSecret request webhook delivery failed after retries: TypeError: fetch failed\n    at node:internal/deps/undici/undici:15845:13\n    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)\n    at async sendSecretRequestWebhook (/app/api/routes/secret-requests.ts:58:34) {\n  [cause]: Error: connect ECONNREFUSED 127.0.0.1:80\n      at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1637:16) {\n    errno: -111,\n    code: 'ECONNREFUSED',\n    syscall: 'connect',\n    address: '127.0.0.1',\n    port: 80\n  }\n}\n```\n### Impact\nWhile the SSRF filter can be bypassed, the practical impact is limited because this is a Blind SSRF, there is no response reflected. But with certain technique like response-timing, the attackers can still indicate whether or not a port is opened.\n\n### Remediation\nReplace hostname-based validation with IP resolution checking:\n```typescript\nimport { isIP } from 'is-ip';\nimport dns from 'dns/promises';\n\nexport const isPublicUrl = async (url: string): Promise<boolean> => {\n    const parsed = new URL(url);\n    const hostname = parsed.hostname;\n    \n    // Resolve hostname to IP\n    let addresses: string[];\n    try {\n        if (isIP(hostname)) {\n            addresses = [hostname];\n        } else {\n            addresses = await dns.resolve4(hostname).catch(() => []);\n            const ipv6 = await dns.resolve6(hostname).catch(() => []);\n            addresses = [...addresses, ...ipv6];\n        }\n    } catch {\n        return false;\n    }\n    \n    // Check resolved IPs against blocklist\n    const privateRanges = [\n        /^127\\./,\n        /^10\\./,\n        /^192\\.168\\./,\n        /^172\\.(1[6-9]|2\\d|3[0-1])\\./,\n        /^169\\.254\\./,\n        /^::1$/,\n        /^fe80:/i,\n        /^fc00:/i,\n        /^fd/i,\n    ];\n    \n    return addresses.length > 0 && !addresses.some(ip => \n        privateRanges.some(pattern => pattern.test(ip))\n    );\n};\n```\nAdditionally, disable following redirects in the webhook fetch call or re-validate the URL after each redirect.","published":"2025-12-29T15:55:12.761Z","modified":"2026-08-12T03:51:09.230281525Z","cvss":{"score":4.3,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N"},"epss":{"score":0.00198,"percentile":0.09731,"asOf":"2026-08-08"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"hemmelig","fixedVersion":"7.3.3"}],"fix":{"url":"https://github.com/HemmeligOrg/Hemmelig.app/commit/6c909e571d0797ee3bbd2c72e4eb767b57378228","label":"HemmeligOrg/Hemmelig.app@6c909e5"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2025/69xxx/CVE-2025-69206.json"},{"type":"ADVISORY","url":"https://github.com/HemmeligOrg/Hemmelig.app/security/advisories/GHSA-vvxf-wj5w-6gj5"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2025-69206"},{"type":"FIX","url":"https://github.com/HemmeligOrg/Hemmelig.app/commit/6c909e571d0797ee3bbd2c72e4eb767b57378228"},{"type":"PACKAGE","url":"https://github.com/HemmeligOrg/Hemmelig.app"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:09.230281525Z"}}