lobe-chat has an Open Redirect
Description
Lobe Chat is an open-source artificial intelligence chat framework. Prior to version 1.130.1, the project's OIDC redirect handling logic constructs the host and protocol of the final redirect URL based on the X-Forwarded-Host or Host headers and the X-Forwarded-Proto value. In deployments where a reverse proxy forwards client-supplied X-Forwarded-* headers to the origin as-is, or where the origin trusts them without validation, an attacker can inject an arbitrary host and trigger an open redirect that sends users to a malicious domain. This issue has been patched in version 1.130.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@lobehub/chatnpm | < 1.130.1 | 1.130.1 |
Affected products
1Patches
170f52a3c1fad🐛 fix: fix oidc open direct issue (#9315)
3 files changed · +549 −7
packages/utils/src/server/correctOIDCUrl.test.ts+466 −0 added@@ -0,0 +1,466 @@ +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'); + }); + }); +});
packages/utils/src/server/correctOIDCUrl.ts+15 −7 modified@@ -1,13 +1,15 @@ import debug from 'debug'; import { NextRequest } from 'next/server'; +import { validateRedirectHost } from './validateRedirectHost'; + const log = debug('lobe-oidc:correctOIDCUrl'); /** - * 修复 OIDC 重定向 URL 在代理环境下的问题 - * @param req - Next.js 请求对象 - * @param url - 要修复的 URL 对象 - * @returns 修复后的 URL 对象 + * Fix OIDC redirect URL issues in proxy environments + * @param req - Next.js request object + * @param url - URL object to fix + * @returns Fixed URL object */ export const correctOIDCUrl = (req: NextRequest, url: URL): URL => { const requestHost = req.headers.get('host'); @@ -23,17 +25,23 @@ export const correctOIDCUrl = (req: NextRequest, url: URL): URL => { forwardedProto, ); - // 确定实际的主机名和协议,提供后备值 + // Determine actual hostname and protocol with fallback values const actualHost = forwardedHost || requestHost; const actualProto = forwardedProto || (url.protocol === 'https:' ? 'https' : 'http'); - // 如果无法确定有效的主机名,直接返回原URL + // If unable to determine valid hostname, return original URL if (!actualHost || actualHost === 'null') { log('Warning: Cannot determine valid host, returning original URL'); return url; } - // 如果 URL 指向本地地址,或者主机名与实际请求主机不匹配,则修正 URL + // Validate target host for security, prevent Open Redirect attacks + if (!validateRedirectHost(actualHost)) { + log('Warning: Target host %s failed validation, returning original URL', actualHost); + return url; + } + + // Correct URL if it points to localhost or hostname doesn't match actual request host const needsCorrection = url.hostname === 'localhost' || url.hostname === '127.0.0.1' ||
packages/utils/src/server/validateRedirectHost.ts+68 −0 added@@ -0,0 +1,68 @@ +import debug from 'debug'; + +const log = debug('lobe-oidc:validateRedirectHost'); + +/** + * Validate if redirect host is in the allowed whitelist + * Prevent Open Redirect attacks + */ +export const validateRedirectHost = (targetHost: string): boolean => { + if (!targetHost || targetHost === 'null') { + log('Invalid target host: %s', targetHost); + return false; + } + + // Get configured APP_URL as base domain + const appUrl = process.env.APP_URL; + if (!appUrl) { + log('Warning: APP_URL not configured, rejecting redirect to: %s', targetHost); + return false; + } + + try { + const appUrlObj = new URL(appUrl); + const appHost = appUrlObj.host; + + log('Validating target host: %s against app host: %s', targetHost, appHost); + + // Exact match + if (targetHost === appHost) { + log('Host validation passed: exact match'); + return true; + } + + // Allow localhost and local addresses (development environment) + const isLocalhost = + targetHost === 'localhost' || + targetHost.startsWith('localhost:') || + targetHost === '127.0.0.1' || + targetHost.startsWith('127.0.0.1:') || + targetHost === '0.0.0.0' || + targetHost.startsWith('0.0.0.0:'); + + if ( + isLocalhost && + (appHost.includes('localhost') || + appHost.includes('127.0.0.1') || + appHost.includes('0.0.0.0')) + ) { + log('Host validation passed: localhost environment'); + return true; + } + + // Check if it's a subdomain of the configured domain + const appDomain = appHost.split(':')[0]; // Remove port number + const targetDomain = targetHost.split(':')[0]; // Remove port number + + if (targetDomain.endsWith('.' + appDomain)) { + log('Host validation passed: subdomain of %s', appDomain); + return true; + } + + console.error('Host validation failed: %s is not allowed', targetHost); + return false; + } catch (error) { + console.error('Error parsing APP_URL %s: %O', appUrl, error); + return false; + } +};
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
5- github.com/advisories/GHSA-xph5-278p-26qxghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-59426ghsaADVISORY
- github.com/lobehub/lobe-chat/blob/aa841a3879c30142720485182ad62aa0dbd74edc/src/app/(backend)/oidc/consent/route.tsghsax_refsource_MISCWEB
- github.com/lobehub/lobe-chat/commit/70f52a3c1fadbd41a9db0e699d1e44d9965de445ghsax_refsource_MISCWEB
- github.com/lobehub/lobe-chat/security/advisories/GHSA-xph5-278p-26qxghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.