fix: authenticate dev proxy with Cloudflare service tokens (#45892)

This commit is contained in:
Joel authored and GitHub committed 2026-11-09 07:08:53 +00:00
1 parent b6d2d2170d
commit 3ec3e61bcb
7 files changed
+148 -6

No files matched your search

+18
View File
@@ -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:
```
+69 -1
View File
@@ -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<typeof fetch>().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<net.Socket>()
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,
+10 -1
View File
@@ -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)
+28 -3
View File
@@ -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<typeof fetch>().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
+1
View File
@@ -25,6 +25,7 @@ export type CookieRewriteOptions = {
export type DevProxyRoute = {
paths: string | readonly string[]
target: string
requestHeaders?: Readonly<Record<string, string>>
cookieRewrite?: CookieRewriteOptions | false
}
+4 -1
View File
@@ -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.
+18
View File
@@ -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,
},
],