VYPR
Moderate severityOSV Advisory· Published Dec 29, 2025· Updated Dec 29, 2025

Hemmelig has SSRF Filter bypass in Secret Request functionality

CVE-2025-69206

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.

PackageAffected versionsPatched versions
hemmelignpm
< 7.3.37.3.3

Affected products

1

Patches

1
6c909e571d07

GHSA-vvxf-wj5w-6gj5: prevent SSRF bypass via DNS rebinding and open redirects in webhook validation

https://github.com/HemmeligOrg/Hemmelig.appBjarne ØverliDec 28, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.