Hemmelig has SSRF Filter bypass in Secret Request functionality
Description
Hemmelig is a messing app with with client-side encryption and self-destructing messages. Prior to version 7.3.3, 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 or open redirect services. This allows an authenticated user to make the server initiate HTTP requests to internal network resources. Version 7.3.3 contains a patch for the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
hemmelignpm | < 7.3.3 | 7.3.3 |
Affected products
1- Range: cli-v1.0.0, cli-v1.0.1, v.1.0.1, …
Patches
16c909e571d07GHSA-vvxf-wj5w-6gj5: prevent SSRF bypass via DNS rebinding and open redirects in webhook validation
5 files changed · +76 −29
api/lib/utils.ts+71 −27 modified@@ -1,4 +1,6 @@ +import dns from 'dns/promises'; import { type Context } from 'hono'; +import { isIP } from 'is-ip'; /** * Handle not found error from Prisma @@ -44,42 +46,84 @@ export const getClientIp = (c: Context): string => { ); }; +// Patterns for private/internal IP addresses +const privateIpPatterns = [ + // Localhost variants + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^0\.0\.0\.0$/, + // Private IPv4 ranges + /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^192\.168\.\d{1,3}\.\d{1,3}$/, + /^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/, + // Link-local IPv4 + /^169\.254\.\d{1,3}\.\d{1,3}$/, + // IPv6 localhost + /^::1$/, + /^\[::1\]$/, + // IPv6 link-local + /^fe80:/i, + // IPv6 private (unique local addresses) + /^fc00:/i, + /^fd[0-9a-f]{2}:/i, +]; + +// Patterns for special domains that should always be blocked +const blockedHostnamePatterns = [ + /^localhost$/, + /\.local$/, + /\.internal$/, + /\.localhost$/, + /\.localdomain$/, +]; + +/** + * Check if an IP address is private/internal + * @param ip IP address to check + * @returns true if IP is private/internal + */ +const isPrivateIp = (ip: string): boolean => { + return privateIpPatterns.some((pattern) => pattern.test(ip)); +}; + /** * Check if a URL points to a private/internal address (SSRF protection) + * Resolves DNS to check actual IP addresses, preventing DNS rebinding attacks. * @param url URL string to validate - * @returns true if URL is safe (not internal), false if it's a private/internal address + * @returns Promise<true> if URL is safe (not internal), Promise<false> if it's a private/internal address */ -export const isPublicUrl = (url: string): boolean => { +export const isPublicUrl = async (url: string): Promise<boolean> => { try { const parsed = new URL(url); const hostname = parsed.hostname.toLowerCase(); - // Block private/internal addresses to prevent SSRF - const blockedPatterns = [ - // Localhost variants - /^localhost$/, - /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, - /^0\.0\.0\.0$/, - /^::1$/, - /^\[::1\]$/, - // Private IPv4 ranges - /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, - /^192\.168\.\d{1,3}\.\d{1,3}$/, - /^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/, - // Link-local - /^169\.254\.\d{1,3}\.\d{1,3}$/, - /^fe80:/i, - // Private IPv6 - /^fc00:/i, - /^fd[0-9a-f]{2}:/i, - // Special domains - /\.local$/, - /\.internal$/, - /\.localhost$/, - /\.localdomain$/, - ]; + // Block special domain patterns (e.g., .local, .localhost) + if (blockedHostnamePatterns.some((pattern) => pattern.test(hostname))) { + return false; + } + + // If hostname is already an IP address, check it directly + if (isIP(hostname)) { + return !isPrivateIp(hostname); + } + + // Resolve DNS to get actual IP addresses + let addresses: string[] = []; + try { + const ipv4Addresses = await dns.resolve4(hostname).catch(() => []); + const ipv6Addresses = await dns.resolve6(hostname).catch(() => []); + addresses = [...ipv4Addresses, ...ipv6Addresses]; + } catch { + // DNS resolution failed - reject for safety + return false; + } + + // Require at least one resolvable address + if (addresses.length === 0) { + return false; + } - return !blockedPatterns.some((pattern) => pattern.test(hostname)); + // Check all resolved IPs - reject if ANY resolve to private addresses + return !addresses.some((ip) => isPrivateIp(ip)); } catch { return false; }
api/lib/webhook.ts+2 −0 modified@@ -73,6 +73,8 @@ export async function sendWebhook( method: 'POST', headers, body: payloadString, + signal: AbortSignal.timeout(5000), // 5 second timeout + redirect: 'error', // Prevent SSRF via open redirects }).catch((error) => { console.error('Failed to send webhook:', error); });
api/routes/secret-requests.ts+1 −0 modified@@ -60,6 +60,7 @@ async function sendSecretRequestWebhook( headers, body: payloadString, signal: AbortSignal.timeout(5000), // 5 second timeout to prevent slow-loris + redirect: 'error', // Prevent SSRF via open redirects }); if (response.ok) return;
api/validations/instance.ts+1 −1 modified@@ -30,7 +30,7 @@ export const instanceSettingsSchema = z.object({ webhookUrl: z .string() .url() - .refine((url) => isPublicUrl(url), { + .refine(async (url) => await isPublicUrl(url), { message: 'Webhook URL cannot point to private/internal addresses', }) .optional()
api/validations/secret-requests.ts+1 −1 modified@@ -49,7 +49,7 @@ export const createSecretRequestSchema = z.object({ webhookUrl: z .string() .url() - .refine((url) => isPublicUrl(url), { + .refine(async (url) => await isPublicUrl(url), { message: 'Webhook URL cannot point to private/internal addresses', }) .optional(),
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-vvxf-wj5w-6gj5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-69206ghsaADVISORY
- github.com/HemmeligOrg/Hemmelig.app/commit/6c909e571d0797ee3bbd2c72e4eb767b57378228ghsax_refsource_MISCWEB
- github.com/HemmeligOrg/Hemmelig.app/security/advisories/GHSA-vvxf-wj5w-6gj5ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.