mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 13:26:58 +08:00
fix: authenticate dev proxy with Cloudflare service tokens (#45892)
This commit is contained in:
7 files changed
+148
-6
No files matched your search
@@ -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:
|
||||
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user