mirror of
https://github.com/langgenius/dify.git
synced 2026-05-25 10:00:43 -04:00
feat(dev-proxy): isolate local auth cookies by target (#36371)
This commit is contained in:
@@ -187,6 +187,22 @@ export default defineDevProxyConfig({
|
||||
|
||||
Set `cookieRewrite: false` to disable cookie rewriting for a route.
|
||||
|
||||
When one local proxy can point to multiple online targets, use `localCookieScope: 'target-origin'`
|
||||
for auth cookies. The proxy stores configured cookies under target-specific local names,
|
||||
forwards only the active target's cookies upstream, and can override a stale frontend CSRF
|
||||
header from the active scoped cookie:
|
||||
|
||||
```ts
|
||||
const cookieRewrite: CookieRewriteOptions = {
|
||||
hostPrefixCookies: ['access_token', 'csrf_token', 'refresh_token'],
|
||||
localCookieScope: 'target-origin',
|
||||
csrfHeader: {
|
||||
cookieName: 'csrf_token',
|
||||
headerName: 'X-CSRF-Token',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- The proxy preserves the matched path prefix when forwarding requests.
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies'
|
||||
import {
|
||||
getCookieHeaderValue,
|
||||
rewriteCookieHeaderForUpstream,
|
||||
rewriteSetCookieHeadersForLocal,
|
||||
toScopedLocalCookieName,
|
||||
} from './cookies'
|
||||
|
||||
describe('dev proxy cookies', () => {
|
||||
// Scenario: cookie names should only receive secure host prefixes when configured.
|
||||
@@ -41,4 +46,57 @@ describe('dev proxy cookies', () => {
|
||||
'access_token=abc; Path=/; SameSite=Lax',
|
||||
])
|
||||
})
|
||||
|
||||
// Scenario: target-scoped cookies should isolate authentication state between upstream targets.
|
||||
it('should only forward auth cookies from the active local scope', () => {
|
||||
// Arrange
|
||||
const activeAccessTokenName = toScopedLocalCookieName('access_token', 'active')
|
||||
const otherAccessTokenName = toScopedLocalCookieName('access_token', 'other')
|
||||
|
||||
// Act
|
||||
const cookieHeader = rewriteCookieHeaderForUpstream([
|
||||
`${activeAccessTokenName}=active-token`,
|
||||
'access_token=legacy-token',
|
||||
`${otherAccessTokenName}=other-token`,
|
||||
'theme=dark',
|
||||
].join('; '), {
|
||||
hostPrefixCookies: ['access_token'],
|
||||
localScopeKey: 'active',
|
||||
useHostPrefix: true,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(cookieHeader).toBe('__Host-access_token=active-token; theme=dark')
|
||||
})
|
||||
|
||||
// Scenario: upstream auth set-cookie headers should be stored under scoped local names.
|
||||
it('should rewrite upstream set-cookie headers into target-scoped local cookies', () => {
|
||||
// Arrange
|
||||
const scopedAccessTokenName = toScopedLocalCookieName('access_token', 'cloud')
|
||||
|
||||
// Act
|
||||
const cookies = rewriteSetCookieHeadersForLocal([
|
||||
'__Host-access_token=abc; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None; Partitioned',
|
||||
], {
|
||||
hostPrefixCookies: ['access_token'],
|
||||
localScopeKey: 'cloud',
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(cookies).toEqual([
|
||||
`${scopedAccessTokenName}=abc; Path=/; SameSite=Lax`,
|
||||
])
|
||||
})
|
||||
|
||||
// Scenario: request header helpers should read scoped CSRF cookies without exposing scope logic to callers.
|
||||
it('should read scoped cookie values from cookie headers', () => {
|
||||
// Arrange
|
||||
const scopedCsrfCookieName = toScopedLocalCookieName('csrf_token', 'cloud')
|
||||
|
||||
// Act
|
||||
const csrfToken = getCookieHeaderValue(`${scopedCsrfCookieName}=csrf; csrf_token=legacy`, scopedCsrfCookieName)
|
||||
|
||||
// Assert
|
||||
expect(csrfToken).toBe('csrf')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CookieRewriteOptions } from './types'
|
||||
|
||||
const SECURE_COOKIE_PREFIX_PATTERN = /^__(Host|Secure)-/
|
||||
const LOCAL_SCOPED_COOKIE_PREFIX = 'dev_proxy'
|
||||
const SAME_SITE_NONE_PATTERN = /^samesite=none$/i
|
||||
const COOKIE_PATH_PATTERN = /^path=/i
|
||||
const COOKIE_DOMAIN_PATTERN = /^domain=/i
|
||||
@@ -14,6 +15,17 @@ const matchesCookieName = (cookieName: string, matcher: string | RegExp) =>
|
||||
? matcher === cookieName
|
||||
: matcher.test(cookieName)
|
||||
|
||||
const hashScope = (scope: string) => {
|
||||
let hash = 0x811C9DC5
|
||||
|
||||
for (let index = 0; index < scope.length; index += 1) {
|
||||
hash ^= scope.charCodeAt(index)
|
||||
hash = Math.imul(hash, 0x01000193)
|
||||
}
|
||||
|
||||
return (hash >>> 0).toString(36)
|
||||
}
|
||||
|
||||
const shouldUseHostPrefix = (cookieName: string, options: CookieRewriteOptions) => {
|
||||
const normalizedCookieName = stripSecureCookiePrefix(cookieName)
|
||||
|
||||
@@ -35,14 +47,29 @@ const toUpstreamCookieName = (cookieName: string, options: CookieRewriteOptions)
|
||||
|
||||
export const toLocalCookieName = (cookieName: string) => stripSecureCookiePrefix(cookieName)
|
||||
|
||||
export const rewriteCookieHeaderForUpstream = (
|
||||
cookieHeader: string | undefined,
|
||||
options: CookieRewriteOptions & { useHostPrefix?: boolean },
|
||||
) => {
|
||||
if (!cookieHeader)
|
||||
return cookieHeader
|
||||
export const resolveCookieRewriteLocalScopeKey = (options: CookieRewriteOptions, targetUrl: URL) => {
|
||||
if (options.localCookieScope === 'target-origin')
|
||||
return hashScope(targetUrl.origin)
|
||||
|
||||
const { useHostPrefix = true } = options
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const toScopedLocalCookieName = (cookieName: string, localScopeKey: string) =>
|
||||
`${LOCAL_SCOPED_COOKIE_PREFIX}_${localScopeKey}_${toLocalCookieName(cookieName)}`
|
||||
|
||||
const fromScopedLocalCookieName = (cookieName: string, localScopeKey: string) => {
|
||||
const scopedPrefix = `${LOCAL_SCOPED_COOKIE_PREFIX}_${localScopeKey}_`
|
||||
if (!cookieName.startsWith(scopedPrefix))
|
||||
return undefined
|
||||
|
||||
return cookieName.slice(scopedPrefix.length)
|
||||
}
|
||||
|
||||
const isScopedLocalCookieName = (cookieName: string) => cookieName.startsWith(`${LOCAL_SCOPED_COOKIE_PREFIX}_`)
|
||||
|
||||
const parseCookieHeader = (cookieHeader: string | undefined) => {
|
||||
if (!cookieHeader)
|
||||
return []
|
||||
|
||||
return cookieHeader
|
||||
.split(/;\s*/)
|
||||
@@ -50,20 +77,62 @@ export const rewriteCookieHeaderForUpstream = (
|
||||
.map((cookie) => {
|
||||
const separatorIndex = cookie.indexOf('=')
|
||||
if (separatorIndex === -1)
|
||||
return cookie
|
||||
return { name: cookie, value: undefined }
|
||||
|
||||
const cookieName = cookie.slice(0, separatorIndex).trim()
|
||||
const cookieValue = cookie.slice(separatorIndex + 1)
|
||||
const upstreamCookieName = useHostPrefix
|
||||
? toUpstreamCookieName(cookieName, options)
|
||||
: cookieName
|
||||
|
||||
return `${upstreamCookieName}=${cookieValue}`
|
||||
return {
|
||||
name: cookie.slice(0, separatorIndex).trim(),
|
||||
value: cookie.slice(separatorIndex + 1),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getCookieHeaderValue = (cookieHeader: string | undefined, cookieName: string) => {
|
||||
const cookie = parseCookieHeader(cookieHeader).find(cookie => cookie.name === cookieName)
|
||||
return cookie?.value
|
||||
}
|
||||
|
||||
export const rewriteCookieHeaderForUpstream = (
|
||||
cookieHeader: string | undefined,
|
||||
options: CookieRewriteOptions & { useHostPrefix?: boolean, localScopeKey?: string },
|
||||
) => {
|
||||
if (!cookieHeader)
|
||||
return cookieHeader
|
||||
|
||||
const { useHostPrefix = true } = options
|
||||
|
||||
return parseCookieHeader(cookieHeader)
|
||||
.map((cookie) => {
|
||||
if (cookie.value === undefined)
|
||||
return cookie.name
|
||||
|
||||
const scopedCookieName = options.localScopeKey
|
||||
? fromScopedLocalCookieName(cookie.name, options.localScopeKey)
|
||||
: undefined
|
||||
|
||||
if (scopedCookieName) {
|
||||
const upstreamCookieName = useHostPrefix
|
||||
? toUpstreamCookieName(scopedCookieName, options)
|
||||
: scopedCookieName
|
||||
return `${upstreamCookieName}=${cookie.value}`
|
||||
}
|
||||
|
||||
if (options.localScopeKey && (isScopedLocalCookieName(cookie.name) || shouldUseHostPrefix(cookie.name, options)))
|
||||
return undefined
|
||||
|
||||
const upstreamCookieName = useHostPrefix
|
||||
? toUpstreamCookieName(cookie.name, options)
|
||||
: cookie.name
|
||||
|
||||
return `${upstreamCookieName}=${cookie.value}`
|
||||
})
|
||||
.filter((cookie): cookie is string => Boolean(cookie))
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
|
||||
const rewriteSetCookieValueForLocal = (
|
||||
setCookieValue: string,
|
||||
options?: CookieRewriteOptions & { localScopeKey?: string },
|
||||
) => {
|
||||
const [rawCookiePair, ...rawAttributes] = setCookieValue.split(';')
|
||||
const separatorIndex = rawCookiePair!.indexOf('=')
|
||||
|
||||
@@ -72,6 +141,13 @@ const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
|
||||
|
||||
const cookieName = rawCookiePair!.slice(0, separatorIndex).trim()
|
||||
const cookieValue = rawCookiePair!.slice(separatorIndex + 1)
|
||||
const localCookieName = toLocalCookieName(cookieName)
|
||||
const shouldScopeCookie = Boolean(
|
||||
options?.localScopeKey && shouldUseHostPrefix(cookieName, options),
|
||||
)
|
||||
const rewrittenCookieName = shouldScopeCookie
|
||||
? toScopedLocalCookieName(cookieName, options!.localScopeKey!)
|
||||
: localCookieName
|
||||
const rewrittenAttributes = rawAttributes
|
||||
.map(attribute => attribute.trim())
|
||||
.filter(attribute =>
|
||||
@@ -89,8 +165,10 @@ const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
|
||||
return attribute
|
||||
})
|
||||
|
||||
return [`${toLocalCookieName(cookieName)}=${cookieValue}`, ...rewrittenAttributes].join('; ')
|
||||
return [`${rewrittenCookieName}=${cookieValue}`, ...rewrittenAttributes].join('; ')
|
||||
}
|
||||
|
||||
export const rewriteSetCookieHeadersForLocal = (setCookieHeaders: readonly string[]) =>
|
||||
setCookieHeaders.map(rewriteSetCookieValueForLocal)
|
||||
export const rewriteSetCookieHeadersForLocal = (
|
||||
setCookieHeaders: readonly string[],
|
||||
options?: CookieRewriteOptions & { localScopeKey?: string },
|
||||
) => setCookieHeaders.map(cookie => rewriteSetCookieValueForLocal(cookie, options))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveCookieRewriteLocalScopeKey, toScopedLocalCookieName } from './cookies'
|
||||
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin } from './server'
|
||||
|
||||
describe('dev proxy server', () => {
|
||||
@@ -155,6 +156,66 @@ describe('dev proxy server', () => {
|
||||
expect(requestHeaders.get('cookie')).toBe('access_token=abc; refresh_token=def')
|
||||
})
|
||||
|
||||
// Scenario: scoped Dify auth cookies should prevent stale local cookies from leaking across targets.
|
||||
it('should proxy target-scoped auth cookies and override stale CSRF headers', async () => {
|
||||
// Arrange
|
||||
const cookieRewrite = {
|
||||
hostPrefixCookies: ['access_token', 'csrf_token', 'refresh_token'],
|
||||
localCookieScope: 'target-origin' as const,
|
||||
csrfHeader: {
|
||||
cookieName: 'csrf_token',
|
||||
headerName: 'X-CSRF-Token',
|
||||
},
|
||||
}
|
||||
const targetUrl = new URL('https://cloud.example.com')
|
||||
const localScopeKey = resolveCookieRewriteLocalScopeKey(cookieRewrite, targetUrl)!
|
||||
const accessTokenCookieName = toScopedLocalCookieName('access_token', localScopeKey)
|
||||
const csrfTokenCookieName = toScopedLocalCookieName('csrf_token', localScopeKey)
|
||||
const otherScopeAccessTokenCookieName = toScopedLocalCookieName('access_token', 'other')
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok', {
|
||||
status: 200,
|
||||
headers: [
|
||||
['set-cookie', '__Host-access_token=next; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None'],
|
||||
],
|
||||
}))
|
||||
const app = createDevProxyApp({
|
||||
routes: [
|
||||
{
|
||||
paths: '/console/api',
|
||||
target: targetUrl.origin,
|
||||
cookieRewrite,
|
||||
},
|
||||
],
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
// Act
|
||||
const response = await app.request('http://127.0.0.1:5001/console/api/apps', {
|
||||
headers: {
|
||||
'Cookie': [
|
||||
`${accessTokenCookieName}=current-access`,
|
||||
`${csrfTokenCookieName}=current-csrf`,
|
||||
'access_token=legacy-access',
|
||||
'csrf_token=legacy-csrf',
|
||||
`${otherScopeAccessTokenCookieName}=other-access`,
|
||||
'theme=dark',
|
||||
].join('; '),
|
||||
'X-CSRF-Token': 'legacy-csrf',
|
||||
},
|
||||
})
|
||||
|
||||
// Assert
|
||||
const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers
|
||||
if (!(requestHeaders instanceof Headers))
|
||||
throw new Error('Expected proxy request headers to be Headers')
|
||||
|
||||
expect(requestHeaders.get('cookie')).toBe('__Host-access_token=current-access; __Host-csrf_token=current-csrf; theme=dark')
|
||||
expect(requestHeaders.get('x-csrf-token')).toBe('current-csrf')
|
||||
expect(response.headers.getSetCookie()).toEqual([
|
||||
`${accessTokenCookieName}=next; Path=/; SameSite=Lax`,
|
||||
])
|
||||
})
|
||||
|
||||
// Scenario: custom route paths should support independent upstream targets.
|
||||
it('should proxy custom route paths to their configured targets', async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { Context, Hono } from 'hono'
|
||||
import type { CookieRewriteOptions, CreateDevProxyAppOptions, DevProxyCorsAllowedOrigins, DevProxyRoute } from './types'
|
||||
import { Hono as HonoApp } from 'hono'
|
||||
import { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies'
|
||||
import {
|
||||
getCookieHeaderValue,
|
||||
resolveCookieRewriteLocalScopeKey,
|
||||
rewriteCookieHeaderForUpstream,
|
||||
rewriteSetCookieHeadersForLocal,
|
||||
toScopedLocalCookieName,
|
||||
} from './cookies'
|
||||
|
||||
const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
|
||||
const ALLOW_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'
|
||||
@@ -100,12 +106,26 @@ const createProxyRequestHeaders = (
|
||||
headers.set('origin', targetUrl.origin)
|
||||
|
||||
if (cookieRewrite) {
|
||||
const originalCookieHeader = headers.get('cookie') || undefined
|
||||
const localScopeKey = resolveCookieRewriteLocalScopeKey(cookieRewrite, targetUrl)
|
||||
const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined, {
|
||||
...cookieRewrite,
|
||||
localScopeKey,
|
||||
useHostPrefix: targetUrl.protocol === 'https:',
|
||||
})
|
||||
if (rewrittenCookieHeader)
|
||||
headers.set('cookie', rewrittenCookieHeader)
|
||||
else
|
||||
headers.delete('cookie')
|
||||
|
||||
if (localScopeKey && cookieRewrite.csrfHeader) {
|
||||
const scopedCsrfCookieName = toScopedLocalCookieName(cookieRewrite.csrfHeader.cookieName, localScopeKey)
|
||||
const scopedCsrfToken = getCookieHeaderValue(originalCookieHeader, scopedCsrfCookieName)
|
||||
if (scopedCsrfToken)
|
||||
headers.set(cookieRewrite.csrfHeader.headerName, scopedCsrfToken)
|
||||
else
|
||||
headers.delete(cookieRewrite.csrfHeader.headerName)
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
@@ -123,6 +143,7 @@ const getSetCookieHeaders = (headers: Headers) => {
|
||||
|
||||
const createUpstreamResponseHeaders = (
|
||||
response: Response,
|
||||
targetUrl: URL,
|
||||
requestOrigin: string | undefined | null,
|
||||
allowedOrigins: DevProxyCorsAllowedOrigins,
|
||||
cookieRewrite: CookieRewriteOptions | false | undefined,
|
||||
@@ -131,9 +152,15 @@ const createUpstreamResponseHeaders = (
|
||||
RESPONSE_HEADERS_TO_DROP.forEach(header => headers.delete(header))
|
||||
headers.delete('set-cookie')
|
||||
|
||||
const localScopeKey = cookieRewrite
|
||||
? resolveCookieRewriteLocalScopeKey(cookieRewrite, targetUrl)
|
||||
: undefined
|
||||
const setCookieHeaders = getSetCookieHeaders(response.headers)
|
||||
const responseSetCookieHeaders = cookieRewrite
|
||||
? rewriteSetCookieHeadersForLocal(setCookieHeaders)
|
||||
? rewriteSetCookieHeadersForLocal(setCookieHeaders, {
|
||||
...cookieRewrite,
|
||||
localScopeKey,
|
||||
})
|
||||
: setCookieHeaders
|
||||
|
||||
responseSetCookieHeaders.forEach((cookie) => {
|
||||
@@ -167,6 +194,7 @@ const proxyRequest = async (
|
||||
const upstreamResponse = await fetchImpl(targetUrl, requestInit)
|
||||
const responseHeaders = createUpstreamResponseHeaders(
|
||||
upstreamResponse,
|
||||
targetUrl,
|
||||
context.req.header('origin'),
|
||||
allowedOrigins,
|
||||
route.cookieRewrite,
|
||||
|
||||
@@ -11,8 +11,15 @@ export type DevProxyCorsConfig = {
|
||||
|
||||
export type CookieNameMatcher = string | RegExp
|
||||
|
||||
export type CookieRewriteLocalScope = 'target-origin'
|
||||
|
||||
export type CookieRewriteOptions = {
|
||||
hostPrefixCookies?: readonly CookieNameMatcher[]
|
||||
localCookieScope?: CookieRewriteLocalScope
|
||||
csrfHeader?: {
|
||||
cookieName: string
|
||||
headerName: string
|
||||
}
|
||||
}
|
||||
|
||||
export type DevProxyRoute = {
|
||||
|
||||
@@ -14,6 +14,11 @@ const difyCookieRewrite: CookieRewriteOptions = {
|
||||
'webapp_access_token',
|
||||
/^passport-/,
|
||||
],
|
||||
localCookieScope: 'target-origin',
|
||||
csrfHeader: {
|
||||
cookieName: 'csrf_token',
|
||||
headerName: 'X-CSRF-Token',
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
Reference in New Issue
Block a user