mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-10 11:34:23 +01:00
🔒 feat: Enhance Actions SSRF Protection with Comprehensive IP and Domain Validation (#10583)
* 🔒 feat: Enhance SSRF Protection with Comprehensive IP and Domain Validation * Added extensive tests for validating IP addresses and domains to prevent SSRF attacks, including checks for internal, private, and link-local addresses. * Improved domain validation logic to handle various edge cases, ensuring only legitimate requests are processed. * Implemented security measures against common cloud provider metadata access and internal service exploitation. * Updated existing tests to reflect changes in validation logic and ensure robust security coverage. * chore: cleanup comments * 🔒 feat: Improve Domain Validation Logic for Enhanced Security * Added logic to extract and normalize hostnames from client-provided domains, including handling of URLs and IP addresses. * Implemented checks using Node.js's net module to validate IP addresses, ensuring robust domain validation. * Updated existing validation conditions to enhance security against potential SSRF attacks. * feat: Additional Protocol Checks and IPv6 Support * Added tests to reject unsupported protocols (FTP, WebSocket, file) in client domains to strengthen SSRF protection. * Improved domain extraction logic to preserve brackets for IPv6 addresses, ensuring correct URL formatting. * Updated validation logic to handle various edge cases for client-provided domains, enhancing overall security. * feat: Expand Domain Validation Tests for Enhanced SSRF Protection * Added comprehensive tests for handling various URL formats, including IPv6 addresses, authentication credentials, and special characters in paths. * Implemented additional validation scenarios for client domains, covering edge cases such as malformed URLs, empty strings, and unsupported protocols. * Enhanced handling of internationalized domain names and localhost variations to ensure robust domain extraction and validation.
This commit is contained in:
parent
9f2fc25bde
commit
086e9a92dc
2 changed files with 706 additions and 32 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
import _axios from 'axios';
|
||||
import { URL } from 'url';
|
||||
import _axios from 'axios';
|
||||
import * as net from 'net';
|
||||
import crypto from 'crypto';
|
||||
import { load } from 'js-yaml';
|
||||
import type { ActionMetadata, ActionMetadataRuntime } from './types/agents';
|
||||
|
|
@ -567,16 +568,18 @@ export type ValidationResult = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Extracts the domain from a URL string.
|
||||
* @param {string} url - The URL to extract the domain from.
|
||||
* @returns {string} The extracted domain (hostname with protocol).
|
||||
* Extracts domain from URL (protocol + hostname).
|
||||
* @param url - URL to extract from
|
||||
* @returns Protocol and hostname (e.g., "https://example.com")
|
||||
*/
|
||||
export function extractDomainFromUrl(url: string): string {
|
||||
try {
|
||||
/** Parsed URL object */
|
||||
const parsedUrl = new URL(url);
|
||||
// Return protocol + hostname (e.g., "https://example.com")
|
||||
// This preserves the protocol which is important for SSRF prevention
|
||||
return `${parsedUrl.protocol}//${parsedUrl.hostname}`;
|
||||
// Preserve brackets for IPv6 addresses using net.isIP
|
||||
const ipVersion = net.isIP(parsedUrl.hostname);
|
||||
const hostname = ipVersion === 6 ? `[${parsedUrl.hostname}]` : parsedUrl.hostname;
|
||||
return `${parsedUrl.protocol}//${hostname}`;
|
||||
} catch {
|
||||
throw new Error(`Invalid URL format: ${url}`);
|
||||
}
|
||||
|
|
@ -590,45 +593,100 @@ export type DomainValidationResult = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Validates that a client-provided domain matches the domain from an OpenAPI spec server URL.
|
||||
* This is critical for preventing SSRF attacks where an attacker provides a whitelisted domain
|
||||
* but uses a different (potentially internal) URL in the raw OpenAPI spec.
|
||||
*
|
||||
* @param {string} clientProvidedDomain - The domain provided by the client (may or may not include protocol)
|
||||
* @param {string} specServerUrl - The server URL from the OpenAPI spec
|
||||
* @returns {DomainValidationResult} Validation result with normalized domains
|
||||
* Validates client domain matches OpenAPI spec server URL domain (SSRF prevention).
|
||||
* @param clientProvidedDomain - Domain from client (with/without protocol)
|
||||
* @param specServerUrl - Server URL from OpenAPI spec
|
||||
* @returns Validation result with normalized domains
|
||||
*/
|
||||
export function validateActionDomain(
|
||||
clientProvidedDomain: string,
|
||||
specServerUrl: string,
|
||||
): DomainValidationResult {
|
||||
try {
|
||||
// Extract domain from the spec's server URL
|
||||
const specDomain = extractDomainFromUrl(specServerUrl);
|
||||
const normalizedSpecDomain = extractDomainFromUrl(specDomain);
|
||||
/** Parsed spec URL */
|
||||
const specUrl = new URL(specServerUrl);
|
||||
|
||||
// Normalize client-provided domain (add https:// if no protocol)
|
||||
const normalizedClientDomain = clientProvidedDomain.startsWith('http')
|
||||
? clientProvidedDomain
|
||||
: `https://${clientProvidedDomain}`;
|
||||
|
||||
// Compare normalized domains
|
||||
// We check both the normalized client domain and the raw client domain
|
||||
// to handle cases where the client might provide "example.com" vs "https://example.com"
|
||||
if (
|
||||
normalizedSpecDomain !== normalizedClientDomain &&
|
||||
normalizedSpecDomain !== clientProvidedDomain
|
||||
) {
|
||||
if (specUrl.protocol !== 'http:' && specUrl.protocol !== 'https:') {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `Domain mismatch: Client provided '${clientProvidedDomain}', but spec uses '${normalizedSpecDomain}'`,
|
||||
message: `Invalid protocol: Only HTTP and HTTPS are allowed, got ${specUrl.protocol}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spec hostname only */
|
||||
const specHostname = specUrl.hostname;
|
||||
/** Spec domain with protocol (handle IPv6 brackets) */
|
||||
const specIpVersion = net.isIP(specHostname);
|
||||
const normalizedSpecDomain =
|
||||
specIpVersion === 6
|
||||
? `${specUrl.protocol}//[${specHostname}]`
|
||||
: `${specUrl.protocol}//${specHostname}`;
|
||||
|
||||
/** Extract hostname from client domain if it's a full URL */
|
||||
let clientHostname = clientProvidedDomain;
|
||||
let clientHasProtocol = false;
|
||||
|
||||
// Check for any protocol in the client domain
|
||||
if (clientProvidedDomain.includes('://')) {
|
||||
if (
|
||||
!clientProvidedDomain.startsWith('http://') &&
|
||||
!clientProvidedDomain.startsWith('https://')
|
||||
) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `Invalid protocol: Only HTTP and HTTPS are allowed in client domain`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const clientUrl = new URL(clientProvidedDomain);
|
||||
clientHostname = clientUrl.hostname;
|
||||
clientHasProtocol = true;
|
||||
} catch {
|
||||
// If parsing fails, treat as hostname
|
||||
clientHasProtocol = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize IPv6 addresses by removing brackets for comparison */
|
||||
const normalizedClientHostname = clientHostname.replace(/^\[(.+)\]$/, '$1');
|
||||
const normalizedSpecHostname = specHostname.replace(/^\[(.+)\]$/, '$1');
|
||||
|
||||
/** Check if hostname is valid IP using Node.js built-in net module */
|
||||
const isIPAddress = net.isIP(normalizedClientHostname) !== 0;
|
||||
|
||||
/** Normalized client domain */
|
||||
let normalizedClientDomain: string;
|
||||
if (clientHasProtocol) {
|
||||
normalizedClientDomain = extractDomainFromUrl(clientProvidedDomain);
|
||||
} else {
|
||||
// IP addresses inherit protocol from spec, domains default to https
|
||||
if (isIPAddress) {
|
||||
// IPv6 addresses need brackets in URLs
|
||||
const ipVersion = net.isIP(normalizedClientHostname);
|
||||
const hostname =
|
||||
ipVersion === 6 && !clientHostname.startsWith('[')
|
||||
? `[${normalizedClientHostname}]`
|
||||
: clientHostname;
|
||||
normalizedClientDomain = `${specUrl.protocol}//${hostname}`;
|
||||
} else {
|
||||
normalizedClientDomain = `https://${clientHostname}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedSpecDomain === normalizedClientDomain ||
|
||||
(!clientHasProtocol && isIPAddress && normalizedClientHostname === normalizedSpecHostname)
|
||||
) {
|
||||
return {
|
||||
isValid: true,
|
||||
normalizedSpecDomain,
|
||||
normalizedClientDomain,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
isValid: false,
|
||||
message: `Domain mismatch: Client provided '${clientProvidedDomain}', but spec uses '${specHostname}'`,
|
||||
normalizedSpecDomain,
|
||||
normalizedClientDomain,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue