@@ -201,6 +201,72 @@ function buildHttpsOrigin(hostname, httpsPort) {
201201 return `https://${ normalizedHostname } ${ portSuffix } ` ;
202202}
203203
204+ function parseAllowedOrigins ( value ) {
205+ const normalized = normalizeOptionalString ( value ) ;
206+ if ( ! normalized ) {
207+ return [ ] ;
208+ }
209+
210+ return normalized
211+ . split ( ',' )
212+ . map ( ( origin ) => origin . trim ( ) )
213+ . filter ( ( origin ) => origin . length > 0 ) ;
214+ }
215+
216+ function parseOriginUrl ( value ) {
217+ try {
218+ return new URL ( value ) ;
219+ } catch {
220+ return null ;
221+ }
222+ }
223+
224+ function normalizeHostname ( value ) {
225+ const normalized = normalizeOptionalString ( value ) ;
226+ if ( ! normalized ) {
227+ return null ;
228+ }
229+
230+ const asUrl = parseOriginUrl ( normalized . includes ( '://' ) ? normalized : `https://${ normalized } ` ) ;
231+ return asUrl ?. hostname ?? null ;
232+ }
233+
234+ function isOriginAllowedByHostname ( origin , hostnames ) {
235+ const originUrl = parseOriginUrl ( origin ) ;
236+ if ( ! originUrl ) {
237+ return false ;
238+ }
239+
240+ return hostnames
241+ . map ( normalizeHostname )
242+ . filter ( Boolean )
243+ . some ( ( hostname ) => originUrl . hostname === hostname ) ;
244+ }
245+
246+ function isInternalApiOriginAllowed ( origin , env = process . env ) {
247+ if ( ! origin ) {
248+ return true ;
249+ }
250+
251+ const allowedOrigins = parseAllowedOrigins ( env . GATEWAY_ALLOWED_ORIGINS ) ;
252+ if ( allowedOrigins . includes ( origin ) ) {
253+ return true ;
254+ }
255+
256+ return isOriginAllowedByHostname ( origin , [
257+ env . GATEWAY_PUBLIC_HOSTNAME ,
258+ env . GATEWAY_HTTPS_HOST ,
259+ ] ) ;
260+ }
261+
262+ function handleCorsError ( error , _req , res , next ) {
263+ if ( ! error || error . message !== 'Not allowed by CORS for internal API' ) {
264+ return next ( error ) ;
265+ }
266+
267+ return res . status ( 403 ) . json ( { message : error . message } ) ;
268+ }
269+
204270function sanitizeRedirectPath ( requestPath = '/' ) {
205271 if ( typeof requestPath !== 'string' || requestPath . trim ( ) . length === 0 || ! requestPath . startsWith ( '/' ) ) {
206272 return '/' ;
@@ -963,18 +1029,18 @@ function createApp({
9631029 app . use ( '/external' , createExternalApiRouter ( { env, fetchImpl, spec } ) ) ;
9641030
9651031 const serviceUrls = buildServiceUrls ( env ) ;
966- const publicHostname = env . GATEWAY_PUBLIC_HOSTNAME || 'localhost' ;
9671032
9681033 // Stricter CORS for the internal API
9691034 app . use ( '/api' , cors ( {
9701035 origin : ( origin , callback ) => {
971- if ( ! origin || origin . includes ( publicHostname ) ) {
1036+ if ( isInternalApiOriginAllowed ( origin , env ) ) {
9721037 callback ( null , true ) ;
9731038 } else {
9741039 callback ( new Error ( 'Not allowed by CORS for internal API' ) ) ;
9751040 }
9761041 }
9771042 } ) ) ;
1043+ app . use ( '/api' , handleCorsError ) ;
9781044
9791045 app . use ( '/api' , createInternalApiRouter ( { serviceUrls, fetchImpl } ) ) ;
9801046
@@ -1061,6 +1127,7 @@ module.exports = {
10611127 STRATEGY_TO_BOT_ID ,
10621128 applyBotMoveToYen,
10631129 buildDocsHtml,
1130+ handleCorsError,
10641131 buildProxy,
10651132 buildRedirectDestination,
10661133 buildHttpsOrigin,
@@ -1070,8 +1137,10 @@ module.exports = {
10701137 getRedirectHostname,
10711138 getProxyRoutes,
10721139 getTlsConfig,
1140+ isInternalApiOriginAllowed,
10731141 isDirectExecution,
10741142 loadTlsOptions,
1143+ parseAllowedOrigins,
10751144 sanitizeRedirectPath,
10761145 pickPlayBotId,
10771146 parseBoolean,
0 commit comments