ghsa-vvxf-wj5w-6gj5
Vulnerability from github
Summary
A 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.
Details
The vulnerability exists in the isPublicUrl function located in /api/lib/utils.ts. The function validates webhook URLs against a blocklist of private IP patterns:
```typescript export const isPublicUrl = (url: string): boolean => { const parsed = new URL(url); const hostname = parsed.hostname.toLowerCase();
const blockedPatterns = [
/^localhost$/,
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
/^192\.168\.\d{1,3}\.\d{1,3}$/,
// ... other patterns
];
return !blockedPatterns.some((pattern) => pattern.test(hostname));
}; ```
The validation is flawed because:
-
DNS Rebinding Bypass: It only checks the hostname string, not the resolved IP address. Domains like
localtest.mepass validation (not matching any blocked pattern) but resolve to127.0.0.1. -
Open Redirect Bypass: External URLs like
httpbin.org/redirect-to?url=http://127.0.0.1pass validation sincehttpbin.orgis a public domain. When the server follows the redirect, it connects to the internal address.
PoC
Optional: On the container that runs Hemmelig application, host a temporary port with the following command:
node -e "require('http').createServer((req,res)=>{console.log(req.method,req.url,req.headers);res.end('ok')}).listen(8080,()=>console.log('Listening on 8080'))"
1. Log in as an user
2. Switch to Secret Requests tab and create a new request
3. When inside the request dialog, there are 2 possible payloads that can be used on the Webhook URL input to bypass SSRF
1. Using domain redirect: http://localtest.me:PORT
2. Using httpbin to perform a redirect: httpbin.org/redirect-to?url=http://127.0.0.1:PORT
4. Open a new browser/tab and confirm the request by creating a secret. Upon clicking save, the port we hosted we receive a request.
Otherwise, if the port doesn't exist, a similar error in the logs can be found:
Secret request webhook delivery failed after retries: TypeError: fetch failed
at node:internal/deps/undici/undici:15845:13
at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
at async sendSecretRequestWebhook (/app/api/routes/secret-requests.ts:58:34) {
[cause]: Error: connect ECONNREFUSED 127.0.0.1:80
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1637:16) {
errno: -111,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 80
}
}
Impact
While 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.
Remediation
Replace hostname-based validation with IP resolution checking: ```typescript import { isIP } from 'is-ip'; import dns from 'dns/promises';
export const isPublicUrl = async (url: string): Promise => { const parsed = new URL(url); const hostname = parsed.hostname;
// Resolve hostname to IP
let addresses: string[];
try {
if (isIP(hostname)) {
addresses = [hostname];
} else {
addresses = await dns.resolve4(hostname).catch(() => []);
const ipv6 = await dns.resolve6(hostname).catch(() => []);
addresses = [...addresses, ...ipv6];
}
} catch {
return false;
}
// Check resolved IPs against blocklist
const privateRanges = [
/^127\./,
/^10\./,
/^192\.168\./,
/^172\.(1[6-9]|2\d|3[0-1])\./,
/^169\.254\./,
/^::1$/,
/^fe80:/i,
/^fc00:/i,
/^fd/i,
];
return addresses.length > 0 && !addresses.some(ip =>
privateRanges.some(pattern => pattern.test(ip))
);
}; ``` Additionally, disable following redirects in the webhook fetch call or re-validate the URL after each redirect.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "hemmelig"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "7.3.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2025-69206"
],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2025-12-29T21:31:04Z",
"nvd_published_at": "2025-12-29T16:15:44Z",
"severity": "MODERATE"
},
"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 =\u003e {\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) =\u003e 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(\u0027http\u0027).createServer((req,res)=\u003e{console.log(req.method,req.url,req.headers);res.end(\u0027ok\u0027)}).listen(8080,()=\u003econsole.log(\u0027Listening on 8080\u0027))\"\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\u003cimg width=\"795\" height=\"310\" alt=\"image\" src=\"https://github.com/user-attachments/assets/95d559e5-ead2-4b5d-8e53-9ddec3416953\" /\u003e\n\nOtherwise, if the port doesn\u0027t 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: \u0027ECONNREFUSED\u0027,\n syscall: \u0027connect\u0027,\n address: \u0027127.0.0.1\u0027,\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 \u0027is-ip\u0027;\nimport dns from \u0027dns/promises\u0027;\n\nexport const isPublicUrl = async (url: string): Promise\u003cboolean\u003e =\u003e {\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(() =\u003e []);\n const ipv6 = await dns.resolve6(hostname).catch(() =\u003e []);\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 \u003e 0 \u0026\u0026 !addresses.some(ip =\u003e \n privateRanges.some(pattern =\u003e pattern.test(ip))\n );\n};\n```\nAdditionally, disable following redirects in the webhook fetch call or re-validate the URL after each redirect.",
"id": "GHSA-vvxf-wj5w-6gj5",
"modified": "2025-12-29T21:31:04Z",
"published": "2025-12-29T21:31:04Z",
"references": [
{
"type": "WEB",
"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": "WEB",
"url": "https://github.com/HemmeligOrg/Hemmelig.app/commit/6c909e571d0797ee3bbd2c72e4eb767b57378228"
},
{
"type": "PACKAGE",
"url": "https://github.com/HemmeligOrg/Hemmelig.app"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "hemmelig allows SSRF Filter bypass via Secret Request functionality"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or seen somewhere by the user.
- Confirmed: The vulnerability is confirmed from an analyst perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: This vulnerability was exploited and seen by the user reporting the sighting.
- Patched: This vulnerability was successfully patched by the user reporting the sighting.
- Not exploited: This vulnerability was not exploited or seen by the user reporting the sighting.
- Not confirmed: The user expresses doubt about the veracity of the vulnerability.
- Not patched: This vulnerability was not successfully patched by the user reporting the sighting.