🔒 fix: Better SSRF protection for Actions (#11143)

Addresses Server-Side Request Forgery vulnerability that allowed authenticated
attackers to interact with arbitrary internal/external HTTP services via the
Actions feature, including the internal RAG API, localhost services, and cloud
metadata endpoints.

## Security Changes

### 1. SSRF Target Blocklist (when allowedDomains is empty)
- Block localhost and loopback addresses (127.0.0.0/8, ::1)
- Block private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- Block link-local/cloud metadata IPs (169.254.0.0/16)
- Block common internal Docker/Kubernetes service names (rag_api, redis, mongo, etc.)
- Block .internal and .local TLDs

Admins can still explicitly allow internal targets by adding them to
`actions.allowedDomains` in librechat.yaml.

### 2. Protocol and Port Restrictions
Enhanced `allowedDomains` to support protocol and port constraints:
- `example.com` - any protocol, any port (existing behavior)
- `https://example.com` - HTTPS only, any port
- `https://api.example.com:8443` - HTTPS only, port 8443 only

This allows admins to restrict actions to specific protocols/ports, preventing
attackers from accessing unintended services on allowed domains.

### 3. Redirect-based SSRF Prevention
Disabled automatic redirect following in action HTTP requests (`maxRedirects: 0`).
This prevents attackers from bypassing domain restrictions by:
1. Pointing action to allowed external domain
2. External domain redirects to internal service (e.g., 127.0.0.1)
3. Server follows redirect and accesses internal service

## Files Changed
- packages/api/src/auth/domain.ts: Added isSSRFTarget(), parseDomainSpec(),
  updated isActionDomainAllowed() with protocol/port matching
- packages/api/src/auth/domain.spec.ts: Added tests for SSRF protection,
  protocol restrictions, and port restrictions
- packages/data-provider/src/actions.ts: Added maxRedirects: 0 to axios config

## Configuration Example
# librechat.yaml
actions:
  allowedDomains:
    - "https://api.example.com"        # HTTPS only
    - "https://api.example.com:8443"   # HTTPS + specific port
    - "http://localhost:3000"          # Admin override for local devRef: SBA-ADV-20251205-02
CVSSv3: 9.1 (Critical) - CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:L
This commit is contained in:
Danny Avila 2025-12-29 15:09:55 -05:00 committed by GitHub
parent a59bab4dc7
commit 4fd09946d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 445 additions and 32 deletions

View file

@ -1,9 +1,10 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import {
isEmailDomainAllowed,
isActionDomainAllowed,
extractMCPServerDomain,
isActionDomainAllowed,
isEmailDomainAllowed,
isMCPDomainAllowed,
isSSRFTarget,
} from './domain';
describe('isEmailDomainAllowed', () => {
@ -105,11 +106,131 @@ describe('isEmailDomainAllowed', () => {
});
});
describe('isSSRFTarget', () => {
describe('localhost blocking', () => {
it('should block localhost', () => {
expect(isSSRFTarget('localhost')).toBe(true);
expect(isSSRFTarget('LOCALHOST')).toBe(true);
expect(isSSRFTarget('localhost.localdomain')).toBe(true);
expect(isSSRFTarget('sub.localhost')).toBe(true);
});
});
describe('IPv4 private ranges', () => {
it('should block 127.0.0.0/8 (loopback)', () => {
expect(isSSRFTarget('127.0.0.1')).toBe(true);
expect(isSSRFTarget('127.255.255.255')).toBe(true);
});
it('should block 10.0.0.0/8 (private)', () => {
expect(isSSRFTarget('10.0.0.1')).toBe(true);
expect(isSSRFTarget('10.255.255.255')).toBe(true);
});
it('should block 172.16.0.0/12 (private)', () => {
expect(isSSRFTarget('172.16.0.1')).toBe(true);
expect(isSSRFTarget('172.31.255.255')).toBe(true);
expect(isSSRFTarget('172.15.0.1')).toBe(false); // Outside range
expect(isSSRFTarget('172.32.0.1')).toBe(false); // Outside range
});
it('should block 192.168.0.0/16 (private)', () => {
expect(isSSRFTarget('192.168.0.1')).toBe(true);
expect(isSSRFTarget('192.168.255.255')).toBe(true);
});
it('should block 169.254.0.0/16 (link-local/cloud metadata)', () => {
expect(isSSRFTarget('169.254.169.254')).toBe(true); // AWS metadata
expect(isSSRFTarget('169.254.0.1')).toBe(true);
});
it('should block 0.0.0.0', () => {
expect(isSSRFTarget('0.0.0.0')).toBe(true);
});
it('should allow public IPs', () => {
expect(isSSRFTarget('8.8.8.8')).toBe(false);
expect(isSSRFTarget('1.1.1.1')).toBe(false);
expect(isSSRFTarget('203.0.113.1')).toBe(false);
});
});
describe('IPv6 blocking', () => {
it('should block IPv6 loopback', () => {
expect(isSSRFTarget('::1')).toBe(true);
expect(isSSRFTarget('::')).toBe(true);
expect(isSSRFTarget('[::1]')).toBe(true);
});
it('should block IPv6 private ranges', () => {
expect(isSSRFTarget('fc00::1')).toBe(true);
expect(isSSRFTarget('fd00::1')).toBe(true);
expect(isSSRFTarget('fe80::1')).toBe(true);
});
});
describe('internal hostnames', () => {
it('should block common internal service names', () => {
expect(isSSRFTarget('rag_api')).toBe(true);
expect(isSSRFTarget('rag-api')).toBe(true);
expect(isSSRFTarget('redis')).toBe(true);
expect(isSSRFTarget('mongodb')).toBe(true);
expect(isSSRFTarget('postgres')).toBe(true);
expect(isSSRFTarget('elasticsearch')).toBe(true);
});
it('should block .internal and .local TLDs', () => {
expect(isSSRFTarget('api.internal')).toBe(true);
expect(isSSRFTarget('service.local')).toBe(true);
});
it('should allow legitimate domains', () => {
expect(isSSRFTarget('api.example.com')).toBe(false);
expect(isSSRFTarget('swagger.io')).toBe(false);
expect(isSSRFTarget('openai.com')).toBe(false);
});
});
});
describe('isActionDomainAllowed', () => {
afterEach(() => {
jest.clearAllMocks();
});
// SSRF Protection Tests
describe('SSRF protection', () => {
it('should block SSRF targets when no allowedDomains configured', async () => {
// These should be blocked when no explicit allowlist
expect(await isActionDomainAllowed('localhost', null)).toBe(false);
expect(await isActionDomainAllowed('127.0.0.1', null)).toBe(false);
expect(await isActionDomainAllowed('10.0.0.1', null)).toBe(false);
expect(await isActionDomainAllowed('192.168.1.1', null)).toBe(false);
expect(await isActionDomainAllowed('169.254.169.254', null)).toBe(false);
expect(await isActionDomainAllowed('rag_api', null)).toBe(false);
expect(await isActionDomainAllowed('http://rag_api:8000', null)).toBe(false);
});
it('should allow public domains with no restrictions', async () => {
expect(await isActionDomainAllowed('api.example.com', null)).toBe(true);
expect(await isActionDomainAllowed('https://openai.com', null)).toBe(true);
});
it('should allow SSRF targets when explicitly in allowedDomains (admin override)', async () => {
// Admins can explicitly allow internal targets if needed
const allowedDomains = ['localhost', '127.0.0.1', 'rag_api'];
expect(await isActionDomainAllowed('localhost', allowedDomains)).toBe(true);
expect(await isActionDomainAllowed('127.0.0.1', allowedDomains)).toBe(true);
expect(await isActionDomainAllowed('rag_api', allowedDomains)).toBe(true);
});
it('should still block SSRF targets not in allowedDomains even when list is configured', async () => {
// Only explicitly allowed domains should work
const allowedDomains = ['example.com'];
expect(await isActionDomainAllowed('localhost', allowedDomains)).toBe(false);
expect(await isActionDomainAllowed('127.0.0.1', allowedDomains)).toBe(false);
});
});
// Basic Input Validation Tests
describe('input validation', () => {
it('should return false for falsy values', async () => {
@ -217,6 +338,98 @@ describe('isActionDomainAllowed', () => {
expect(await isActionDomainAllowed('test.com', invalidAllowedDomains)).toBe(true);
});
});
// Protocol and Port Restrictions (Recommendation #2)
describe('protocol and port restrictions', () => {
describe('protocol-only restrictions', () => {
const httpsOnlyDomains = ['https://api.example.com', 'https://secure.test.com'];
it('should allow HTTPS when HTTPS is required', async () => {
expect(await isActionDomainAllowed('https://api.example.com', httpsOnlyDomains)).toBe(true);
expect(await isActionDomainAllowed('https://secure.test.com', httpsOnlyDomains)).toBe(true);
});
it('should deny HTTP when HTTPS is required', async () => {
expect(await isActionDomainAllowed('http://api.example.com', httpsOnlyDomains)).toBe(false);
expect(await isActionDomainAllowed('http://secure.test.com', httpsOnlyDomains)).toBe(false);
});
it('should deny domain without protocol when protocol is required', async () => {
// When allowedDomains specifies protocol, input should also have protocol
expect(await isActionDomainAllowed('api.example.com', httpsOnlyDomains)).toBe(false);
});
});
describe('port restrictions', () => {
const portRestrictedDomains = ['https://api.example.com:443', 'http://internal:8080'];
it('should allow matching port', async () => {
expect(
await isActionDomainAllowed('https://api.example.com:443', portRestrictedDomains),
).toBe(true);
expect(await isActionDomainAllowed('http://internal:8080', portRestrictedDomains)).toBe(
true,
);
});
it('should deny different port', async () => {
expect(
await isActionDomainAllowed('https://api.example.com:8443', portRestrictedDomains),
).toBe(false);
expect(await isActionDomainAllowed('http://internal:9000', portRestrictedDomains)).toBe(
false,
);
});
it('should deny when no port specified but port required', async () => {
expect(await isActionDomainAllowed('https://api.example.com', portRestrictedDomains)).toBe(
false,
);
});
});
describe('mixed restrictions', () => {
const mixedDomains = [
'example.com', // Any protocol, any port
'https://secure.example.com', // HTTPS only, default port
'https://api.example.com:8443', // HTTPS only, specific port
'http://localhost:3000', // HTTP only, specific port (admin override for internal)
];
it('should allow any protocol/port for unrestricted domain', async () => {
expect(await isActionDomainAllowed('http://example.com', mixedDomains)).toBe(true);
expect(await isActionDomainAllowed('https://example.com', mixedDomains)).toBe(true);
expect(await isActionDomainAllowed('https://example.com:8080', mixedDomains)).toBe(true);
expect(await isActionDomainAllowed('example.com', mixedDomains)).toBe(true);
});
it('should enforce protocol for protocol-restricted domain', async () => {
expect(await isActionDomainAllowed('https://secure.example.com', mixedDomains)).toBe(true);
expect(await isActionDomainAllowed('http://secure.example.com', mixedDomains)).toBe(false);
});
it('should enforce both protocol and port when both specified', async () => {
expect(await isActionDomainAllowed('https://api.example.com:8443', mixedDomains)).toBe(
true,
);
expect(await isActionDomainAllowed('http://api.example.com:8443', mixedDomains)).toBe(
false,
);
expect(await isActionDomainAllowed('https://api.example.com:443', mixedDomains)).toBe(
false,
);
expect(await isActionDomainAllowed('https://api.example.com', mixedDomains)).toBe(false);
});
it('should allow internal targets with explicit protocol/port (admin override)', async () => {
expect(await isActionDomainAllowed('http://localhost:3000', mixedDomains)).toBe(true);
// Different port should fail
expect(await isActionDomainAllowed('http://localhost:8080', mixedDomains)).toBe(false);
// Different protocol should fail
expect(await isActionDomainAllowed('https://localhost:3000', mixedDomains)).toBe(false);
});
});
});
});
describe('extractMCPServerDomain', () => {
@ -343,7 +556,8 @@ describe('isMCPDomainAllowed', () => {
expect(await isMCPDomainAllowed(config, allowedDomains)).toBe(true);
});
it('should allow localhost', async () => {
it('should allow localhost when explicitly in allowedDomains (admin override)', async () => {
// Admins can explicitly allow localhost for local MCP servers
const config = { url: 'http://localhost:3001/sse' };
expect(await isMCPDomainAllowed(config, allowedDomains)).toBe(true);
});

View file

@ -23,11 +23,134 @@ export function isEmailDomainAllowed(email: string, allowedDomains?: string[] |
}
/**
* Normalizes a domain string. If the domain is invalid, returns null.
* Normalized === lowercase, trimmed, and protocol added if missing.
* @param domain
* SSRF Protection: Checks if a hostname/IP is a potentially dangerous internal target.
* Blocks private IPs, localhost, cloud metadata IPs, and common internal hostnames.
* @param hostname - The hostname or IP to check
* @returns true if the target is blocked (SSRF risk), false if safe
*/
function normalizeDomain(domain: string): string | null {
export function isSSRFTarget(hostname: string): boolean {
const normalizedHost = hostname.toLowerCase().trim();
// Block localhost variations
if (
normalizedHost === 'localhost' ||
normalizedHost === 'localhost.localdomain' ||
normalizedHost.endsWith('.localhost')
) {
return true;
}
// Check if it's an IP address and block private/internal ranges
const ipv4Match = normalizedHost.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [, a, b, c] = ipv4Match.map(Number);
// 127.0.0.0/8 - Loopback
if (a === 127) {
return true;
}
// 10.0.0.0/8 - Private
if (a === 10) {
return true;
}
// 172.16.0.0/12 - Private (172.16.x.x - 172.31.x.x)
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
// 192.168.0.0/16 - Private
if (a === 192 && b === 168) {
return true;
}
// 169.254.0.0/16 - Link-local (includes cloud metadata 169.254.169.254)
if (a === 169 && b === 254) {
return true;
}
// 0.0.0.0 - Special
if (a === 0 && b === 0 && c === 0) {
return true;
}
}
// IPv6 loopback and private ranges
const ipv6Normalized = normalizedHost.replace(/^\[|\]$/g, ''); // Remove brackets if present
if (
ipv6Normalized === '::1' ||
ipv6Normalized === '::' ||
ipv6Normalized.startsWith('fc') || // fc00::/7 - Unique local
ipv6Normalized.startsWith('fd') || // fd00::/8 - Unique local
ipv6Normalized.startsWith('fe80') // fe80::/10 - Link-local
) {
return true;
}
// Block common internal Docker/Kubernetes service names
const internalHostnames = [
'rag_api',
'rag-api',
'api',
'redis',
'mongodb',
'mongo',
'postgres',
'postgresql',
'mysql',
'database',
'db',
'elasticsearch',
'kibana',
'grafana',
'prometheus',
'rabbitmq',
'kafka',
'zookeeper',
'consul',
'vault',
'etcd',
'minio',
'internal',
'backend',
'metadata', // Common metadata service name
];
if (internalHostnames.includes(normalizedHost)) {
return true;
}
// Block .internal and .local TLDs (common in internal networks)
if (normalizedHost.endsWith('.internal') || normalizedHost.endsWith('.local')) {
return true;
}
return false;
}
/**
* Parsed domain specification including protocol and port constraints.
*/
interface ParsedDomainSpec {
hostname: string;
protocol?: 'http:' | 'https:' | null; // null means any protocol
port?: string | null; // null means any port
explicitPort: boolean; // true if port was explicitly specified in original string
isWildcard: boolean;
}
/**
* Parses a domain specification into its components.
* Supports formats:
* - `example.com` (any protocol, any port)
* - `https://example.com` (https only, any port)
* - `https://example.com:443` (https only, port 443)
* - `*.example.com` (wildcard subdomain)
* @param domain - Domain specification string
* @returns ParsedDomainSpec or null if invalid
*/
function parseDomainSpec(domain: string): ParsedDomainSpec | null {
try {
let normalizedDomain = domain.toLowerCase().trim();
@ -36,27 +159,71 @@ function normalizeDomain(domain: string): string | null {
return null;
}
// If it's not already a URL, make it one
if (!normalizedDomain.startsWith('http://') && !normalizedDomain.startsWith('https://')) {
// Check for wildcard prefix before parsing
const isWildcard = normalizedDomain.startsWith('*.');
// Check if it has a protocol
const hasProtocol =
normalizedDomain.startsWith('http://') || normalizedDomain.startsWith('https://');
// Check if port was explicitly specified (e.g., :443, :8080)
// Need to check before URL parsing because URL normalizes default ports
const portMatch = normalizedDomain.match(/:(\d+)(\/|$|\?)/);
const explicitPort = portMatch !== null;
const explicitPortValue = portMatch ? portMatch[1] : null;
// If no protocol, add one temporarily for URL parsing
if (!hasProtocol) {
normalizedDomain = `https://${normalizedDomain}`;
}
const url = new URL(normalizedDomain);
// Additional validation that hostname isn't just protocol
if (!url.hostname || url.hostname === 'http:' || url.hostname === 'https:') {
return null;
}
return url.hostname.replace(/^www\./i, '');
const hostname = url.hostname.replace(/^www\./i, '');
return {
hostname,
protocol: hasProtocol ? (url.protocol as 'http:' | 'https:') : null,
// Use the explicitly specified port, or null if no port was specified
port: explicitPort ? explicitPortValue : null,
explicitPort,
isWildcard,
};
} catch {
return null;
}
}
/**
* Checks if the given domain is allowed. If no restrictions are set, allows all domains.
* @param domain
* @param allowedDomains
* Checks if hostname matches an allowed pattern (supports wildcards).
*/
function hostnameMatches(inputHostname: string, allowedSpec: ParsedDomainSpec): boolean {
if (allowedSpec.isWildcard) {
// Extract base domain from wildcard (e.g., "*.example.com" -> "example.com")
const baseDomain = allowedSpec.hostname.replace(/^\*\./, '');
return inputHostname === baseDomain || inputHostname.endsWith(`.${baseDomain}`);
}
return inputHostname === allowedSpec.hostname;
}
/**
* Checks if the given domain is allowed.
* SECURITY: When no allowedDomains is configured, blocks SSRF-prone targets
* (private IPs, localhost, metadata services) to prevent attacks.
* When allowedDomains IS configured, admins can explicitly allow internal targets if needed.
*
* Supports protocol and port restrictions in allowedDomains:
* - `example.com` - allows any protocol/port
* - `https://example.com` - allows only HTTPS on default port
* - `https://example.com:8443` - allows only HTTPS on port 8443
*
* @param domain - The domain to check (can include protocol/port)
* @param allowedDomains - List of allowed domain patterns
*/
export async function isActionDomainAllowed(
domain?: string | null,
@ -66,32 +233,50 @@ export async function isActionDomainAllowed(
return false;
}
if (!Array.isArray(allowedDomains) || !allowedDomains.length) {
return true;
}
const normalizedInputDomain = normalizeDomain(domain);
if (!normalizedInputDomain) {
const inputSpec = parseDomainSpec(domain);
if (!inputSpec) {
return false;
}
/** If no domain restrictions configured, block SSRF targets but allow all else */
if (!Array.isArray(allowedDomains) || !allowedDomains.length) {
/** SECURITY: Block SSRF-prone targets when no allowlist is configured */
if (isSSRFTarget(inputSpec.hostname)) {
return false;
}
return true;
}
/** When allowedDomains is configured, check against the list with protocol/port matching */
for (const allowedDomain of allowedDomains) {
const normalizedAllowedDomain = normalizeDomain(allowedDomain);
if (!normalizedAllowedDomain) {
const allowedSpec = parseDomainSpec(allowedDomain);
if (!allowedSpec) {
continue;
}
if (normalizedAllowedDomain.startsWith('*.')) {
const baseDomain = normalizedAllowedDomain.slice(2);
if (
normalizedInputDomain === baseDomain ||
normalizedInputDomain.endsWith(`.${baseDomain}`)
) {
return true;
}
} else if (normalizedInputDomain === normalizedAllowedDomain) {
return true;
// Check hostname match (with wildcard support)
if (!hostnameMatches(inputSpec.hostname, allowedSpec)) {
continue;
}
// If allowedSpec has protocol restriction, input must match
if (allowedSpec.protocol !== null) {
// Input must have protocol specified to match a protocol-restricted rule
if (inputSpec.protocol === null || inputSpec.protocol !== allowedSpec.protocol) {
continue;
}
}
// If allowedSpec has explicit port restriction, input must have matching explicit port
if (allowedSpec.explicitPort) {
// Input must also have an explicit port that matches
if (!inputSpec.explicitPort || inputSpec.port !== allowedSpec.port) {
continue;
}
}
// All specified constraints matched
return true;
}
return false;

View file

@ -290,7 +290,21 @@ class RequestExecutor {
...(this.config.contentType ? { 'Content-Type': this.config.contentType } : {}),
};
const method = this.config.method.toLowerCase();
const axios = _axios.create();
/**
* SECURITY: Disable automatic redirects to prevent SSRF bypass.
* Attackers could use redirects to access internal services:
* 1. Set action URL to allowed external domain
* 2. External domain redirects to internal service (e.g., 127.0.0.1, rag_api)
* 3. Without this protection, axios would follow the redirect
*
* By setting maxRedirects: 0, we prevent this attack vector.
* The action will receive the redirect response (3xx) instead of following it.
*/
const axios = _axios.create({
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400, // Accept 3xx but don't follow
});
// Initialize separate containers for query and body parameters.
const queryParams: Record<string, unknown> = {};