Repository navigation
Expand file tree
/
Copy pathproxy.ts
More file actions
160 lines (138 loc) · 5.67 KB
/
Copy pathproxy.ts
File metadata and controls
160 lines (138 loc) · 5.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
// oxlint-disable-next-line no-restricted-imports
import type { NextRequest } from 'next/server'
import { Buffer } from 'node:buffer'
// oxlint-disable-next-line no-restricted-imports
import { NextResponse } from 'next/server'
import { env } from '@/env'
import { getWebAppDocumentResponse } from '@/features/app-access-error/document-response'
const NECESSARY_DOMAIN =
'*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://cdn-cookieyes.com https://ungh.cc https://api2.amplitude.com *.amplitude.com'
const COOKIEYES_LOGGING_ORIGIN = 'https://log.cookieyes.com'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
const CURRENT_SEARCH_HEADER = 'x-dify-search'
const EMBEDDABLE_PATH_SEGMENTS = [
'/agent',
'/chat',
'/chatbot',
'/completion',
'/webapp-signin',
'/workflow',
]
const NON_EMBEDDABLE_PATH_SEGMENTS = ['/device']
const FRAME_ANCESTORS_NONE = "'none'"
const LEGACY_EDUCATION_ACTION = 'getEducationVerify'
const getHttpOrigin = (value: string | undefined) => {
if (!value) return ''
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : ''
} catch {
return ''
}
}
const matchesPathSegment = (pathname: string, segments: string[]) =>
segments.some((segment) => pathname === segment || pathname.startsWith(`${segment}/`))
export const canEmbedPath = (pathname: string) =>
matchesPathSegment(pathname, EMBEDDABLE_PATH_SEGMENTS)
const appendFrameAncestors = (response: NextResponse, frameOrigin: string) => {
const existingCsp = response.headers.get('Content-Security-Policy')
if (existingCsp?.includes('frame-ancestors')) return
response.headers.set(
'Content-Security-Policy',
`${existingCsp ? `${existingCsp} ` : ''}frame-ancestors ${frameOrigin};`,
)
}
const wrapResponseWithFrameProtection = (response: NextResponse, pathname: string) => {
// Published app routes are intentionally embeddable; all other routes default to clickjacking protection.
const preventEmbedding =
matchesPathSegment(pathname, NON_EMBEDDABLE_PATH_SEGMENTS) ||
(env.NEXT_PUBLIC_ALLOW_EMBED !== true && !canEmbedPath(pathname))
if (preventEmbedding) {
response.headers.set('X-Frame-Options', 'DENY')
appendFrameAncestors(response, FRAME_ANCESTORS_NONE)
}
return response
}
export async function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl
// TODO(2026-11-11): Remove after external education CTAs and active campaign links use the canonical route.
if (pathname === '/' && request.nextUrl.searchParams.get('action') === LEGACY_EDUCATION_ACTION) {
const destination = request.nextUrl.clone()
destination.pathname = '/education/verify'
destination.searchParams.delete('action')
return wrapResponseWithFrameProtection(
NextResponse.redirect(destination, { status: 308 }),
pathname,
)
}
const requestHeaders = new Headers(request.headers)
requestHeaders.set(CURRENT_PATHNAME_HEADER, pathname)
requestHeaders.set(CURRENT_SEARCH_HEADER, search)
const isWhiteListEnabled =
!!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production'
if (!isWhiteListEnabled) {
const response =
(await getWebAppDocumentResponse(request, requestHeaders)) ??
NextResponse.next({
request: {
headers: requestHeaders,
},
})
return wrapResponseWithFrameProtection(response, pathname)
}
const turnstileOrigin = env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
? ' https://challenges.cloudflare.com'
: ''
const whiteList = `${env.NEXT_PUBLIC_CSP_WHITELIST} ${NECESSARY_DOMAIN}${turnstileOrigin}`
const marketplaceFrameOrigin = getHttpOrigin(env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX)
const marketplaceFrameSrc = marketplaceFrameOrigin ? ` ${marketplaceFrameOrigin}` : ''
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const csp = `'nonce-${nonce}'`
const scheme_source = 'data: mediastream: blob: filesystem:'
const cspHeader = `
default-src 'self' ${scheme_source} ${csp} ${whiteList};
connect-src 'self' ${scheme_source} ${csp} ${whiteList} ${COOKIEYES_LOGGING_ORIGIN};
script-src 'self' 'wasm-unsafe-eval' ${scheme_source} ${csp} ${whiteList};
style-src 'self' 'unsafe-inline' ${scheme_source} ${whiteList};
worker-src 'self' ${scheme_source} ${csp} ${whiteList};
media-src 'self' ${scheme_source} ${csp} ${whiteList};
frame-src 'self' ${scheme_source} ${whiteList}${marketplaceFrameSrc};
img-src * data: blob:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
`
// Replace newline characters and spaces
const contentSecurityPolicyHeaderValue = cspHeader.replace(/\s{2,}/g, ' ').trim()
requestHeaders.set('x-nonce', nonce)
requestHeaders.set('Content-Security-Policy', contentSecurityPolicyHeaderValue)
const response =
(await getWebAppDocumentResponse(request, requestHeaders)) ??
NextResponse.next({
request: {
headers: requestHeaders,
},
})
response.headers.set('Content-Security-Policy', contentSecurityPolicyHeaderValue)
return wrapResponseWithFrameProtection(response, pathname)
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - favicon.ico (favicon file)
*/
{
source: '/((?!_next/static|favicon.ico).*)',
// source: '/(.*)',
// missing: [
// { type: 'header', key: 'next-router-prefetch' },
// { type: 'header', key: 'purpose', value: 'prefetch' },
// ],
},
],
}