@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
467 lines (372 loc) • 18.6 kB
text/typescript
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { correctOIDCUrl } from './correctOIDCUrl';
describe('correctOIDCUrl', () => {
let mockRequest: NextRequest;
let originalAppUrl: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
// Store original APP_URL and set default for tests
originalAppUrl = process.env.APP_URL;
process.env.APP_URL = 'https://example.com';
// Create a mock request with a mutable headers property
mockRequest = {
headers: {
get: vi.fn(),
},
} as unknown as NextRequest;
});
afterEach(() => {
// Restore original APP_URL
if (originalAppUrl === undefined) {
delete process.env.APP_URL;
} else {
process.env.APP_URL = originalAppUrl;
}
});
describe('when no forwarded headers are present', () => {
it('should return original URL when host matches and protocol is correct', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
return null;
});
const originalUrl = new URL('https://example.com/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('https://example.com/auth/callback');
expect(result).toBe(originalUrl); // Should return the same object
});
it('should correct localhost URLs to request host preserving port', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
expect(result.host).toBe('example.com:3000');
expect(result.hostname).toBe('example.com');
expect(result.port).toBe('3000');
expect(result.protocol).toBe('http:');
});
it('should correct 127.0.0.1 URLs to request host preserving port', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
return null;
});
const originalUrl = new URL('http://127.0.0.1:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
expect(result.host).toBe('example.com:3000');
expect(result.hostname).toBe('example.com');
});
it('should correct 0.0.0.0 URLs to request host preserving port', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
return null;
});
const originalUrl = new URL('http://0.0.0.0:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
expect(result.host).toBe('example.com:3000');
expect(result.hostname).toBe('example.com');
});
it('should correct mismatched hostnames', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
return null;
});
const originalUrl = new URL('https://different.com/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('https://example.com/auth/callback');
expect(result.host).toBe('example.com');
});
it('should handle request host with port when correcting localhost', () => {
process.env.APP_URL = 'https://example.com:8080';
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com:8080';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('http://example.com:8080/auth/callback');
expect(result.host).toBe('example.com:8080');
expect(result.hostname).toBe('example.com');
expect(result.port).toBe('8080');
});
});
describe('when x-forwarded-host header is present', () => {
it('should use x-forwarded-host over host header', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'internal.com';
if (header === 'x-forwarded-host') return 'proxy.example.com';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('http://proxy.example.com:3000/auth/callback');
expect(result.host).toBe('proxy.example.com:3000');
expect(result.hostname).toBe('proxy.example.com');
});
it('should preserve path and query parameters', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'internal.com';
if (header === 'x-forwarded-host') return 'proxy.example.com';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback?code=123&state=abc');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe(
'http://proxy.example.com:3000/auth/callback?code=123&state=abc',
);
expect(result.pathname).toBe('/auth/callback');
expect(result.search).toBe('?code=123&state=abc');
});
});
describe('when x-forwarded-proto header is present', () => {
it('should use x-forwarded-proto for protocol', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
if (header === 'x-forwarded-proto') return 'https';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('https://example.com:3000/auth/callback');
expect(result.protocol).toBe('https:');
});
it('should use x-forwarded-protocol as fallback', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
if (header === 'x-forwarded-protocol') return 'https';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('https://example.com:3000/auth/callback');
expect(result.protocol).toBe('https:');
});
it('should prioritize x-forwarded-proto over x-forwarded-protocol', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
if (header === 'x-forwarded-proto') return 'https';
if (header === 'x-forwarded-protocol') return 'http';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('https://example.com:3000/auth/callback');
expect(result.protocol).toBe('https:');
});
});
describe('protocol inference when no forwarded protocol', () => {
it('should infer https when original URL uses https', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
return null;
});
const originalUrl = new URL('https://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('https://example.com:3000/auth/callback');
expect(result.protocol).toBe('https:');
});
it('should default to http when original URL uses http', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
expect(result.protocol).toBe('http:');
});
});
describe('edge cases', () => {
it('should return original URL when host is null', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return null;
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result).toBe(originalUrl);
expect(result.toString()).toBe('http://localhost:3000/auth/callback');
});
it('should return original URL when host is "null" string', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'null';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result).toBe(originalUrl);
expect(result.toString()).toBe('http://localhost:3000/auth/callback');
});
it('should return original URL when no host header is present', () => {
(mockRequest.headers.get as any).mockImplementation(() => null);
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result).toBe(originalUrl);
expect(result.toString()).toBe('http://localhost:3000/auth/callback');
});
it('should handle URL construction errors gracefully', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
return null;
});
const originalUrl = new URL(
'http://localhost:3000/auth/callback?redirect=http://example.com',
);
// Spy on URL constructor to simulate an error on correction
const urlSpy = vi.spyOn(global, 'URL');
urlSpy.mockImplementationOnce((url: string | URL, base?: string | URL) => new URL(url, base)); // First call succeeds (original)
urlSpy.mockImplementationOnce(() => {
throw new Error('Invalid URL');
}); // Second call fails (correction)
const result = correctOIDCUrl(mockRequest, originalUrl);
// Should return original URL when correction fails
expect(result).toBe(originalUrl);
expect(result.toString()).toBe(
'http://localhost:3000/auth/callback?redirect=http://example.com',
);
urlSpy.mockRestore();
});
});
describe('complex scenarios', () => {
it('should handle complete proxy scenario with all headers', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'internal-service:3000';
if (header === 'x-forwarded-host') return 'api.example.com';
if (header === 'x-forwarded-proto') return 'https';
return null;
});
const originalUrl = new URL('http://localhost:8080/api/auth/callback?code=xyz&state=def');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe(
'https://api.example.com:8080/api/auth/callback?code=xyz&state=def',
);
expect(result.protocol).toBe('https:');
expect(result.host).toBe('api.example.com:8080');
expect(result.hostname).toBe('api.example.com');
expect(result.pathname).toBe('/api/auth/callback');
expect(result.search).toBe('?code=xyz&state=def');
});
it('should preserve URL hash fragments', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
if (header === 'x-forwarded-proto') return 'https';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback#access_token=123');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result.toString()).toBe('https://example.com:3000/auth/callback#access_token=123');
expect(result.hash).toBe('#access_token=123');
});
it('should reject forwarded host with non-standard port for security', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'internal.com:3000';
if (header === 'x-forwarded-host') return 'example.com:8443';
if (header === 'x-forwarded-proto') return 'https';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
// Should return original URL because example.com:8443 doesn't match configured APP_URL (https://example.com)
expect(result).toBe(originalUrl);
expect(result.toString()).toBe('http://localhost:3000/auth/callback');
});
it('should not need correction when URL hostname matches actual host', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
return null;
});
const originalUrl = new URL('http://example.com/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
expect(result).toBe(originalUrl); // Should return the same object
expect(result.toString()).toBe('http://example.com/auth/callback');
});
});
describe('Open Redirect protection', () => {
it('should prevent redirection to malicious external domains', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'malicious.com';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
// Should return original URL and not redirect to malicious.com
expect(result).toBe(originalUrl);
expect(result.toString()).toBe('http://localhost:3000/auth/callback');
});
it('should allow redirection to configured domain (example.com)', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
// Should allow correction to example.com (configured in APP_URL)
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
expect(result.host).toBe('example.com:3000');
});
it('should allow redirection to subdomains of configured domain', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'api.example.com';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
// Should allow correction to subdomain of example.com
expect(result.toString()).toBe('http://api.example.com:3000/auth/callback');
expect(result.host).toBe('api.example.com:3000');
});
it('should prevent redirection via x-forwarded-host to malicious domains', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'example.com'; // Trusted internal host
if (header === 'x-forwarded-host') return 'evil.com'; // Malicious forwarded host
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
// Should return original URL and not redirect to evil.com
expect(result).toBe(originalUrl);
expect(result.toString()).toBe('http://localhost:3000/auth/callback');
});
it('should allow localhost in development environment', () => {
// Set APP_URL to localhost for development testing
process.env.APP_URL = 'http://localhost:3000';
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'localhost:8080';
return null;
});
const originalUrl = new URL('http://127.0.0.1:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
// Should allow correction to localhost in dev environment
expect(result.toString()).toBe('http://localhost:8080/auth/callback');
expect(result.host).toBe('localhost:8080');
});
it('should prevent redirection when APP_URL is not configured', () => {
// Remove APP_URL to simulate missing configuration
delete process.env.APP_URL;
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'any-domain.com';
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
// Should return original URL when APP_URL is not configured
expect(result).toBe(originalUrl);
expect(result.toString()).toBe('http://localhost:3000/auth/callback');
});
it('should handle domains that look like subdomains but are not', () => {
(mockRequest.headers.get as any).mockImplementation((header: string) => {
if (header === 'host') return 'fakeexample.com'; // Not a subdomain of example.com
return null;
});
const originalUrl = new URL('http://localhost:3000/auth/callback');
const result = correctOIDCUrl(mockRequest, originalUrl);
// Should prevent redirection to fake domain
expect(result).toBe(originalUrl);
expect(result.toString()).toBe('http://localhost:3000/auth/callback');
});
});
});