feat(dev-proxy): isolate local auth cookies by target (#36371)

This commit is contained in:
yyh
2026-05-19 13:59:55 +08:00
committed by GitHub
parent 2031d31ee8
commit 674cdc3521
7 changed files with 275 additions and 22 deletions

View File

@@ -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.

View File

@@ -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')
})
})

View File

@@ -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))

View File

@@ -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

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -14,6 +14,11 @@ const difyCookieRewrite: CookieRewriteOptions = {
'webapp_access_token',
/^passport-/,
],
localCookieScope: 'target-origin',
csrfHeader: {
cookieName: 'csrf_token',
headerName: 'X-CSRF-Token',
},
}
export default {