VYPR
Moderate severityNVD Advisory· Published Sep 25, 2025· Updated Sep 25, 2025

lobe-chat has an Open Redirect

CVE-2025-59426

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.

PackageAffected versionsPatched versions
@lobehub/chatnpm
< 1.130.11.130.1

Affected products

1

Patches

1
70f52a3c1fad

🐛 fix: fix oidc open direct issue (#9315)

https://github.com/lobehub/lobe-chatArvin XuSep 18, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.