diff --git a/packages/dev-proxy/README.md b/packages/dev-proxy/README.md index 6b9d7298c4..fff99a9123 100644 --- a/packages/dev-proxy/README.md +++ b/packages/dev-proxy/README.md @@ -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. diff --git a/packages/dev-proxy/src/cookies.spec.ts b/packages/dev-proxy/src/cookies.spec.ts index 4a1b614eeb..02bce36eee 100644 --- a/packages/dev-proxy/src/cookies.spec.ts +++ b/packages/dev-proxy/src/cookies.spec.ts @@ -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') + }) }) diff --git a/packages/dev-proxy/src/cookies.ts b/packages/dev-proxy/src/cookies.ts index 61fdb6abd4..b9deb6d214 100644 --- a/packages/dev-proxy/src/cookies.ts +++ b/packages/dev-proxy/src/cookies.ts @@ -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)) diff --git a/packages/dev-proxy/src/server.spec.ts b/packages/dev-proxy/src/server.spec.ts index 32c16a1807..1a60d44f9d 100644 --- a/packages/dev-proxy/src/server.spec.ts +++ b/packages/dev-proxy/src/server.spec.ts @@ -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().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 diff --git a/packages/dev-proxy/src/server.ts b/packages/dev-proxy/src/server.ts index 79654750da..4719edfdfe 100644 --- a/packages/dev-proxy/src/server.ts +++ b/packages/dev-proxy/src/server.ts @@ -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, diff --git a/packages/dev-proxy/src/types.ts b/packages/dev-proxy/src/types.ts index 2c42b2f7fb..5257ffaa50 100644 --- a/packages/dev-proxy/src/types.ts +++ b/packages/dev-proxy/src/types.ts @@ -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 = { diff --git a/web/dev-proxy.config.ts b/web/dev-proxy.config.ts index d9787795b3..c3d1528fb0 100644 --- a/web/dev-proxy.config.ts +++ b/web/dev-proxy.config.ts @@ -14,6 +14,11 @@ const difyCookieRewrite: CookieRewriteOptions = { 'webapp_access_token', /^passport-/, ], + localCookieScope: 'target-origin', + csrfHeader: { + cookieName: 'csrf_token', + headerName: 'X-CSRF-Token', + }, } export default {