🫷 fix: Validate User-Provided Base URL in Endpoint Init (#12248)

* 🛡️ fix: Block SSRF via user-provided baseURL in endpoint initialization

User-provided baseURL values (when endpoint is configured with
`user_provided`) were passed through to the OpenAI SDK without
validation. Combined with `directEndpoint`, this allowed arbitrary
server-side requests to internal/metadata URLs.

Adds `validateEndpointURL` that checks against known SSRF targets
and DNS-resolves hostnames to block private IPs. Applied in both
custom and OpenAI endpoint initialization paths.

* 🧪 test: Add validateEndpointURL SSRF tests

Covers unparseable URLs, localhost, private IPs, link-local/metadata,
internal Docker/K8s hostnames, DNS resolution to private IPs, and
legitimate public URLs.

* 🛡️ fix: Add protocol enforcement and import order fix

- Reject non-HTTP/HTTPS schemes (ftp://, file://, data:, etc.) in
  validateEndpointURL before SSRF hostname checks
- Document DNS rebinding limitation and fail-open semantics in JSDoc
- Fix import order in custom/initialize.ts per project conventions

* 🧪 test: Expand SSRF validation coverage and add initializer integration tests

Unit tests for validateEndpointURL:
- Non-HTTP/HTTPS schemes (ftp, file, data)
- IPv6 loopback, link-local, and unique-local addresses
- .local and .internal TLD hostnames
- DNS fail-open path (lookup failure allows request)

Integration tests for initializeCustom and initializeOpenAI:
- Guard fires when userProvidesURL is true
- Guard skipped when URL is system-defined or falsy
- SSRF rejection propagates and prevents getOpenAIConfig call

* 🐛 fix: Correct broken env restore in OpenAI initialize spec

process.env was captured by reference, not by value, making the
restore closure a no-op. Snapshot individual env keys before mutation
so they can be properly restored after each test.

* 🛡️ fix: Throw structured ErrorTypes for SSRF base URL validation

Replace plain-string Error throws in validateEndpointURL with
JSON-structured errors using type 'invalid_base_url' (matching new
ErrorTypes.INVALID_BASE_URL enum value). This ensures the client-side
Error component can look up a localized message instead of falling
through to the raw-text default.

Changes across workspaces:
- data-provider: add INVALID_BASE_URL to ErrorTypes enum
- packages/api: throwInvalidBaseURL helper emits structured JSON
- client: add errorMessages entry and localization key
- tests: add structured JSON format assertion

* 🧹 refactor: Use ErrorTypes enum key in Error.tsx for consistency

Replace bare string literal 'invalid_base_url' with computed property
[ErrorTypes.INVALID_BASE_URL] to match every other entry in the
errorMessages map.
This commit is contained in:
Danny Avila 2026-03-15 18:41:59 -04:00 committed by GitHub
parent f9927f0168
commit f7ab5e645a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 446 additions and 1 deletions

View file

@ -12,6 +12,7 @@ import {
isPrivateIP,
isSSRFTarget,
resolveHostnameSSRF,
validateEndpointURL,
} from './domain';
const mockedLookup = lookup as jest.MockedFunction<typeof lookup>;
@ -1209,3 +1210,135 @@ describe('isMCPDomainAllowed', () => {
});
});
});
describe('validateEndpointURL', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should throw for unparseable URLs', async () => {
await expect(validateEndpointURL('not-a-url', 'test-ep')).rejects.toThrow(
'Invalid base URL for test-ep',
);
});
it('should throw for localhost URLs', async () => {
await expect(validateEndpointURL('http://localhost:8080/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for private IP URLs', async () => {
await expect(validateEndpointURL('http://192.168.1.1/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
await expect(validateEndpointURL('http://10.0.0.1/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
await expect(validateEndpointURL('http://172.16.0.1/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for link-local / metadata IP', async () => {
await expect(
validateEndpointURL('http://169.254.169.254/latest/meta-data/', 'test-ep'),
).rejects.toThrow('targets a restricted address');
});
it('should throw for loopback IP', async () => {
await expect(validateEndpointURL('http://127.0.0.1:11434/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for internal Docker/Kubernetes hostnames', async () => {
await expect(validateEndpointURL('http://redis:6379/', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
await expect(validateEndpointURL('http://mongodb:27017/', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw when hostname DNS-resolves to a private IP', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
await expect(validateEndpointURL('https://evil.example.com/v1', 'test-ep')).rejects.toThrow(
'resolves to a restricted address',
);
});
it('should allow public URLs', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '104.18.7.192', family: 4 }] as never);
await expect(
validateEndpointURL('https://api.openai.com/v1', 'test-ep'),
).resolves.toBeUndefined();
});
it('should allow public URLs that resolve to public IPs', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }] as never);
await expect(
validateEndpointURL('https://api.example.com/v1/chat', 'test-ep'),
).resolves.toBeUndefined();
});
it('should throw for non-HTTP/HTTPS schemes', async () => {
await expect(validateEndpointURL('ftp://example.com/v1', 'test-ep')).rejects.toThrow(
'only HTTP and HTTPS are permitted',
);
await expect(validateEndpointURL('file:///etc/passwd', 'test-ep')).rejects.toThrow(
'only HTTP and HTTPS are permitted',
);
await expect(validateEndpointURL('data:text/plain,hello', 'test-ep')).rejects.toThrow(
'only HTTP and HTTPS are permitted',
);
});
it('should throw for IPv6 loopback URL', async () => {
await expect(validateEndpointURL('http://[::1]:8080/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for IPv6 link-local URL', async () => {
await expect(validateEndpointURL('http://[fe80::1]/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for IPv6 unique-local URL', async () => {
await expect(validateEndpointURL('http://[fc00::1]/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for .local TLD hostname', async () => {
await expect(validateEndpointURL('http://myservice.local/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for .internal TLD hostname', async () => {
await expect(validateEndpointURL('http://api.internal/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should pass when DNS lookup fails (fail-open)', async () => {
mockedLookup.mockRejectedValueOnce(new Error('ENOTFOUND'));
await expect(
validateEndpointURL('https://nonexistent.example.com/v1', 'test-ep'),
).resolves.toBeUndefined();
});
it('should throw structured JSON with type invalid_base_url', async () => {
const error = await validateEndpointURL('http://169.254.169.254/latest/', 'my-ep').catch(
(err: Error) => err,
);
expect(error).toBeInstanceOf(Error);
const parsed = JSON.parse((error as Error).message);
expect(parsed.type).toBe('invalid_base_url');
expect(parsed.message).toContain('my-ep');
expect(parsed.message).toContain('targets a restricted address');
});
});

View file

@ -499,3 +499,45 @@ export async function isMCPDomainAllowed(
// Use MCP_PROTOCOLS (HTTP/HTTPS/WS/WSS) for MCP server validation
return isDomainAllowedCore(domain, allowedDomains, MCP_PROTOCOLS);
}
/** Matches ErrorTypes.INVALID_BASE_URL — string literal avoids build-time dependency on data-provider */
const INVALID_BASE_URL_TYPE = 'invalid_base_url';
function throwInvalidBaseURL(message: string): never {
throw new Error(JSON.stringify({ type: INVALID_BASE_URL_TYPE, message }));
}
/**
* Validates that a user-provided endpoint URL does not target private/internal addresses.
* Throws if the URL is unparseable, uses a non-HTTP(S) scheme, targets a known SSRF hostname,
* or DNS-resolves to a private IP.
*
* @note DNS rebinding: validation performs a single DNS lookup. An adversary controlling
* DNS with TTL=0 could respond with a public IP at validation time and a private IP
* at request time. This is an accepted limitation of point-in-time DNS checks.
* @note Fail-open on DNS errors: a resolution failure here implies a failure at request
* time as well, matching {@link resolveHostnameSSRF} semantics.
*/
export async function validateEndpointURL(url: string, endpoint: string): Promise<void> {
let hostname: string;
let protocol: string;
try {
const parsed = new URL(url);
hostname = parsed.hostname;
protocol = parsed.protocol;
} catch {
throwInvalidBaseURL(`Invalid base URL for ${endpoint}: unable to parse URL.`);
}
if (protocol !== 'http:' && protocol !== 'https:') {
throwInvalidBaseURL(`Invalid base URL for ${endpoint}: only HTTP and HTTPS are permitted.`);
}
if (isSSRFTarget(hostname)) {
throwInvalidBaseURL(`Base URL for ${endpoint} targets a restricted address.`);
}
if (await resolveHostnameSSRF(hostname)) {
throwInvalidBaseURL(`Base URL for ${endpoint} resolves to a restricted address.`);
}
}