diff --git a/packages/dev-proxy/README.md b/packages/dev-proxy/README.md index d30878a74ba..0a2a60d7c46 100644 --- a/packages/dev-proxy/README.md +++ b/packages/dev-proxy/README.md @@ -70,6 +70,24 @@ Config files can be `.ts`, `.mts`, `.js`, or `.mjs`. `routes` are matched in declaration order. The first matching route wins. Each configured path matches both the exact path and all child paths, so `paths: '/api'` matches `/api`, `/api/apps`, and `/api/apps/123`. +Use route-level `requestHeaders` for upstream gateway service tokens. Configured headers replace incoming values for HTTP requests, WebSocket upgrades, and session imports, before normal proxy header sanitization and cookie rewriting. Keep credentials in an ignored local env file and configure them only on routes for the intended upstream. + +`requestHeaders` is optional. In Dify Web, `DEV_PROXY_CF_ACCESS_CLIENT_ID` and `DEV_PROXY_CF_ACCESS_CLIENT_SECRET` are also optional: leave both unset to run the proxy without Cloudflare Access service-token headers. To enable service-token authentication, set both values in `.env.local`. When enabled, Dify Web sends these headers to all configured proxy targets. + +```ts +{ + paths: '/api', + target: 'https://example.com', + requestHeaders: + process.env.DEV_PROXY_CF_ACCESS_CLIENT_ID && process.env.DEV_PROXY_CF_ACCESS_CLIENT_SECRET + ? { + 'CF-Access-Client-Id': process.env.DEV_PROXY_CF_ACCESS_CLIENT_ID, + 'CF-Access-Client-Secret': process.env.DEV_PROXY_CF_ACCESS_CLIENT_SECRET, + } + : undefined, +} +``` + By default, credentialed CORS is allowed for local development origins such as `localhost`, `127.0.0.1`, and `::1`. To restrict it to specific origins: ``` diff --git a/packages/dev-proxy/src/server.spec.ts b/packages/dev-proxy/src/server.spec.ts index 1348e4866b2..6d07cc4b608 100644 --- a/packages/dev-proxy/src/server.spec.ts +++ b/packages/dev-proxy/src/server.spec.ts @@ -161,6 +161,56 @@ describe('dev proxy server', () => { expect(requestHeaders.get('cookie')).toBe('access_token=abc; refresh_token=def') }) + // Scenario: configured gateway headers must override stale client headers without changing proxy invariants. + it('should merge configured request headers only into the matching route', async () => { + // Arrange + const fetchImpl = vi.fn().mockImplementation(async () => new Response('ok')) + const app = createDevProxyApp({ + routes: [ + { + paths: '/console/api', + target: 'https://cloud.example.com', + cookieRewrite: { hostPrefixCookies: ['access_token'] }, + requestHeaders: { + 'CF-Access-Client-Id': 'configured-id', + 'CF-Access-Client-Secret': 'configured-secret', + Host: 'wrong.example.com', + Connection: 'keep-alive, X-Hop', + 'X-Hop': 'configured-hop', + 'Accept-Encoding': 'gzip', + Origin: 'https://wrong.example.com', + }, + }, + { paths: '/api', target: 'https://other.example.com' }, + ], + fetchImpl, + }) + + // Act + await app.request('http://localhost:5001/console/api/apps', { + headers: { + Cookie: 'access_token=abc; theme=dark', + 'cf-access-client-id': 'stale-id', + 'cf-access-client-secret': 'stale-secret', + }, + }) + await app.request('http://localhost:5001/api/apps') + + // Assert + const configuredHeaders = new Headers(fetchImpl.mock.calls[0]?.[1]?.headers) + expect(configuredHeaders.get('cf-access-client-id')).toBe('configured-id') + expect(configuredHeaders.get('cf-access-client-secret')).toBe('configured-secret') + expect(configuredHeaders.get('cookie')).toBe('__Host-access_token=abc; theme=dark') + expect(configuredHeaders.get('host')).toBeNull() + expect(configuredHeaders.get('connection')).toBeNull() + expect(configuredHeaders.get('x-hop')).toBeNull() + expect(configuredHeaders.get('accept-encoding')).toBe('identity') + expect(configuredHeaders.get('origin')).toBe('https://cloud.example.com') + const otherHeaders = new Headers(fetchImpl.mock.calls[1]?.[1]?.headers) + expect(otherHeaders.get('cf-access-client-id')).toBeNull() + expect(otherHeaders.get('cf-access-client-secret')).toBeNull() + }) + // Scenario: local HTTP upstreams expect local cookie names even when cookie rewriting is configured. it('should keep local cookie names for HTTP upstream targets', async () => { // Arrange @@ -295,6 +345,8 @@ describe('dev proxy server', () => { const sockets = new Set() let upstreamRequest: | { + clientId: string | string[] | undefined + clientSecret: string | string[] | undefined connection: string | undefined cookie: string | undefined keepAlive: string | string[] | undefined @@ -310,6 +362,8 @@ describe('dev proxy server', () => { upstreamServer.on('connection', (socket) => sockets.add(socket)) upstreamServer.on('upgrade', (request, socket) => { upstreamRequest = { + clientId: request.headers['cf-access-client-id'], + clientSecret: request.headers['cf-access-client-secret'], connection: request.headers.connection, cookie: request.headers.cookie, keepAlive: request.headers['keep-alive'], @@ -349,7 +403,17 @@ describe('dev proxy server', () => { proxyServer.on( 'upgrade', createWebSocketUpgradeHandler({ - routes: [{ paths: '/socket.io', target: upstreamOrigin, cookieRewrite }], + routes: [ + { + paths: '/socket.io', + target: upstreamOrigin, + cookieRewrite, + requestHeaders: { + 'CF-Access-Client-Id': 'configured-id', + 'CF-Access-Client-Secret': 'configured-secret', + }, + }, + ], }), ) const proxyPort = await listen(proxyServer) @@ -378,6 +442,8 @@ describe('dev proxy server', () => { 'Proxy-Authorization: Basic secret', 'TE: trailers', 'Origin: http://localhost:3000', + 'CF-Access-Client-Id: stale-id', + 'CF-Access-Client-Secret: stale-secret', `Cookie: ${accessTokenCookieName}=secret`, '', '', @@ -410,6 +476,8 @@ describe('dev proxy server', () => { expect(response).not.toContain('__Host-access_token') expect(response).not.toContain('__Secure-refresh_token') expect(upstreamRequest).toEqual({ + clientId: 'configured-id', + clientSecret: 'configured-secret', connection: 'Upgrade', cookie: 'access_token=secret', keepAlive: undefined, diff --git a/packages/dev-proxy/src/server.ts b/packages/dev-proxy/src/server.ts index 7c708d3b8e4..e084d18c1c6 100644 --- a/packages/dev-proxy/src/server.ts +++ b/packages/dev-proxy/src/server.ts @@ -124,8 +124,10 @@ const createProxyRequestHeaders = ( headers: Headers, targetUrl: URL, cookieRewrite: CookieRewriteOptions | false | undefined, + requestHeaders?: DevProxyRoute['requestHeaders'], ) => { const upstreamHeaders = new Headers(headers) + Object.entries(requestHeaders || {}).forEach(([name, value]) => upstreamHeaders.set(name, value)) removeHopByHopHeaders(upstreamHeaders) upstreamHeaders.delete('host') upstreamHeaders.set('accept-encoding', UPSTREAM_ACCEPT_ENCODING) @@ -212,6 +214,7 @@ const proxyRequest = async ( context.req.raw.headers, targetUrl, route.cookieRewrite, + route.requestHeaders, ) const requestInit: RequestInit & { duplex?: 'half' } = { method: context.req.method, @@ -345,6 +348,7 @@ export const createWebSocketUpgradeHandler = ( createHeadersFromIncomingMessage(request), targetUrl, route.cookieRewrite, + route.requestHeaders, ) requestHeaders.set('connection', 'Upgrade') requestHeaders.set('upgrade', upgradeProtocol) @@ -496,7 +500,12 @@ const registerSessionImportRoute = ( const upstreamResponse = await fetchImpl(refreshUrl, { method: 'POST', - headers: createProxyRequestHeaders(localHeaders, refreshUrl, route.cookieRewrite), + headers: createProxyRequestHeaders( + localHeaders, + refreshUrl, + route.cookieRewrite, + route.requestHeaders, + ), redirect: 'manual', }) if (!upstreamResponse.ok) diff --git a/packages/dev-proxy/src/session-import.spec.ts b/packages/dev-proxy/src/session-import.spec.ts index d3357c468e7..2deca6f82dd 100644 --- a/packages/dev-proxy/src/session-import.spec.ts +++ b/packages/dev-proxy/src/session-import.spec.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import type { CookieRewriteOptions } from './types' +import type { CookieRewriteOptions, DevProxyRoute } from './types' import { describe, expect, it, vi } from 'vite-plus/test' import { resolveCookieRewriteLocalScopeKey, toScopedLocalCookieName } from './cookies' import { createDevProxyApp } from './server' @@ -12,9 +12,11 @@ const cookieRewrite: CookieRewriteOptions = { localCookieScope: 'target-origin', } -const createApp = (fetchImpl: typeof fetch) => +const createApp = (fetchImpl: typeof fetch, requestHeaders?: DevProxyRoute['requestHeaders']) => createDevProxyApp({ - routes: [{ paths: '/console/api', target: 'https://cloud.example.com', cookieRewrite }], + routes: [ + { paths: '/console/api', target: 'https://cloud.example.com', cookieRewrite, requestHeaders }, + ], sessionImport: { refreshPath: '/console/api/refresh-token', cookieName: 'refresh_token', @@ -92,6 +94,29 @@ describe('session import', () => { ]) }) + // Scenario: session imports must authenticate with configured gateway service headers. + it('should include configured request headers when refreshing an imported session', async () => { + // Arrange + const fetchImpl = vi.fn().mockResolvedValue( + new Response('{}', { + headers: { 'set-cookie': '__Host-access_token=new-access; Path=/; Secure; HttpOnly' }, + }), + ) + + // Act + const response = await createApp(fetchImpl, { + 'CF-Access-Client-Id': 'configured-id', + 'CF-Access-Client-Secret': 'configured-secret', + }).request(postInput('old-refresh')) + + // Assert + const headers = new Headers(fetchImpl.mock.calls[0]?.[1]?.headers) + expect(headers.get('cookie')).toBe('__Host-refresh_token=old-refresh') + expect(headers.get('cf-access-client-id')).toBe('configured-id') + expect(headers.get('cf-access-client-secret')).toBe('configured-secret') + expect(response.status).toBe(303) + }) + // Scenario: an expired or already-rotated token should be reported instead of redirecting. it('should show the upstream error when refresh fails', async () => { // Arrange diff --git a/packages/dev-proxy/src/types.ts b/packages/dev-proxy/src/types.ts index e9664706000..f08f397d00c 100644 --- a/packages/dev-proxy/src/types.ts +++ b/packages/dev-proxy/src/types.ts @@ -25,6 +25,7 @@ export type CookieRewriteOptions = { export type DevProxyRoute = { paths: string | readonly string[] target: string + requestHeaders?: Readonly> cookieRewrite?: CookieRewriteOptions | false } diff --git a/web/.env.example b/web/.env.example index 5fccfca0f2e..1bb5347c8e6 100644 --- a/web/.env.example +++ b/web/.env.example @@ -20,7 +20,10 @@ NEXT_PUBLIC_SOCKET_URL=ws://localhost:5001 # Dev proxy routes are configured in web/dev-proxy.config.ts. # pnpm -C web run dev:proxy loads web/.env.local before evaluating that config file. -DEV_PROXY_TARGET=https://cloud.dify.ai +DEV_PROXY_TARGET= +# Optional Cloudflare Access service token. Set both values in .env.local. +DEV_PROXY_CF_ACCESS_CLIENT_ID= +DEV_PROXY_CF_ACCESS_CLIENT_SECRET= # Defaults to DEV_PROXY_TARGET when omitted. Set this when enterprise API routes use a different origin. DEV_PROXY_ENTERPRISE_TARGET= # Defaults to DEV_PROXY_TARGET when omitted. Set this when Web App public APIs use a different origin. diff --git a/web/dev-proxy.config.ts b/web/dev-proxy.config.ts index 19646e4e0b1..3e979e3115f 100644 --- a/web/dev-proxy.config.ts +++ b/web/dev-proxy.config.ts @@ -6,6 +6,21 @@ const DEV_PROXY_ENTERPRISE_TARGET = process.env.DEV_PROXY_ENTERPRISE_TARGET || D const DEV_PROXY_PUBLIC_TARGET = process.env.DEV_PROXY_PUBLIC_TARGET || DEV_PROXY_TARGET const DEV_PROXY_HOST = process.env.DEV_PROXY_HOST || '127.0.0.1' const DEV_PROXY_PORT = Number(process.env.DEV_PROXY_PORT || 5001) +const DEV_PROXY_CF_ACCESS_CLIENT_ID = process.env.DEV_PROXY_CF_ACCESS_CLIENT_ID +const DEV_PROXY_CF_ACCESS_CLIENT_SECRET = process.env.DEV_PROXY_CF_ACCESS_CLIENT_SECRET + +if (Boolean(DEV_PROXY_CF_ACCESS_CLIENT_ID) !== Boolean(DEV_PROXY_CF_ACCESS_CLIENT_SECRET)) + throw new Error( + 'Configure both DEV_PROXY_CF_ACCESS_CLIENT_ID and DEV_PROXY_CF_ACCESS_CLIENT_SECRET.', + ) + +const cloudflareAccessHeaders = + DEV_PROXY_CF_ACCESS_CLIENT_ID && DEV_PROXY_CF_ACCESS_CLIENT_SECRET + ? { + 'CF-Access-Client-Id': DEV_PROXY_CF_ACCESS_CLIENT_ID, + 'CF-Access-Client-Secret': DEV_PROXY_CF_ACCESS_CLIENT_SECRET, + } + : undefined const difyCookieRewrite: CookieRewriteOptions = { hostPrefixCookies: [ @@ -42,16 +57,19 @@ export default { '/v1/plugin-manager', ], target: DEV_PROXY_ENTERPRISE_TARGET, + requestHeaders: cloudflareAccessHeaders, cookieRewrite: difyCookieRewrite, }, { paths: ['/console/api', '/socket.io'], target: DEV_PROXY_TARGET, + requestHeaders: cloudflareAccessHeaders, cookieRewrite: difyCookieRewrite, }, { paths: ['/api'], target: DEV_PROXY_PUBLIC_TARGET, + requestHeaders: cloudflareAccessHeaders, cookieRewrite: difyCookieRewrite, }, ],