UNPKG

@interopio/gateway-server

Version:

## `@glue42/gateway-ent` compatibility

312 lines (271 loc) 10 kB
import { WebExchange, HttpRequest, ReadonlyHttpHeaders, ServerHttpResponse, ServerHttpRequest } from './types.ts'; import getLogger from '../logger.js'; import {IOGateway} from '@interopio/gateway'; import {HttpServerResponse} from './exchange.ts'; function isSameOrigin(request: HttpRequest<ReadonlyHttpHeaders>) { const origin = request.headers.one('origin'); if (origin === undefined) { return true; } const url = request.URL; const actualProtocol = url.protocol; const actualHost = url.host; const originUrl = new URL(origin); const originHost = originUrl.host; const originProtocol = originUrl.protocol; return actualProtocol === originProtocol && actualHost === originHost; } /** * Returns `true` if the request is a valid CORS one by checking `Origin` header presence and ensuring origins differ. */ export function isCorsRequest(request: HttpRequest<ReadonlyHttpHeaders>): boolean { return request.headers.has('origin') && !isSameOrigin(request); } export function isPreFlightRequest(request: HttpRequest): boolean { return request.method === 'OPTIONS' && request.headers.has('origin') && request.headers.has('access-control-request-method'); } const VARY_HEADERS: readonly string[] = ['Origin', 'Access-Control-Request-Method', 'Access-Control-Request-Headers']; /** * Processes a request given a {@link CorsConfig}. * * @param exchange the current exchange * @param config the CORS configuration to use, possibly `undefined` in which case pre-flight requests are rejected, but all others allowed * @returns `false` if the request is rejected, `true` otherwise */ export function processRequest(exchange: WebExchange, config?: CorsConfig): boolean { const {request, response} = exchange; const responseHeaders = response.headers; if (!responseHeaders.has('Vary')) { responseHeaders.set('Vary', VARY_HEADERS.join(', ')); } else { const varyHeaders = responseHeaders.list('Vary'); for (const header of VARY_HEADERS) { if (!varyHeaders.find(h => h === header)) { varyHeaders.push(header); } } responseHeaders.set('Vary', varyHeaders.join(', ')); } try { if (!isCorsRequest(request)) { return true; } } catch (e) { if(logger.enabledFor('debug')) { logger.debug(`reject: origin is malformed`); } rejectRequest(response); return false; } if (responseHeaders.has('access-control-allow-origin')) { logger.trace(`skip: already contains "Access-Control-Allow-Origin"`); return true; } const preFlightRequest = isPreFlightRequest(request); if (config) { return handleInternal(exchange, config, preFlightRequest); } if (preFlightRequest) { rejectRequest(response); return false; } return true; } export type CorsConfig = { origins?: { allow?: '*' | IOGateway.Filtering.Matcher[] } methods?: { allow?: '*' | string[] } headers?: { allow?: '*' | string[] expose?: string[] } credentials?: { allow?: boolean }, privateNetwork?: { allow?: boolean } } export /*testing*/ function validateConfig(config?: CorsConfig): CorsConfig | undefined { if (config) { const headers = config.headers; if (headers?.allow && headers.allow !== ALL) { headers.allow = headers.allow.map(header => header.toLowerCase()); } const origins = config.origins; if (origins?.allow && origins.allow !== ALL) { origins.allow = origins.allow.map(origin => { if (typeof origin === 'string') { // exact match return origin.toLowerCase(); } return origin; }); } return config; } } const handler = (config?: CorsConfig) => { validateConfig(config); return async (ctx: WebExchange<ServerHttpRequest, HttpServerResponse>, next: () => Promise<void>) => { const isValid = processRequest(ctx, config); if (!isValid || isPreFlightRequest(ctx.request)) { // do we need to call end? ctx.response._res.end(); } else { await next(); } }; }; export default (config?: CorsConfig) => [handler(config)]; const logger = getLogger('cors'); function rejectRequest(response: ServerHttpResponse) { response.statusCode = 403; } function handleInternal(exchange: WebExchange, config: CorsConfig, preFlightRequest: boolean): boolean { const {request, response} = exchange; const responseHeaders = response.headers; const requestOrigin = request.headers.one('origin'); const allowOrigin = checkOrigin(config, requestOrigin); if (allowOrigin === undefined) { if (logger.enabledFor('debug')) { logger.debug(`reject: '${requestOrigin}' origin is not allowed`); } rejectRequest(response); return false; } const requestMethod = getMethodToUse(request, preFlightRequest); const allowMethods = checkMethods(config, requestMethod); if (allowMethods === undefined) { if (logger.enabledFor('debug')) { logger.debug(`reject: HTTP '${requestMethod}' is not allowed`); } rejectRequest(response); return false; } const requestHeaders = getHeadersToUse(request, preFlightRequest); const allowHeaders = checkHeaders(config, requestHeaders); if (preFlightRequest && allowHeaders === undefined) { if (logger.enabledFor('debug')) { logger.debug(`reject: headers '${requestHeaders}' are not allowed`); } rejectRequest(response); return false; } responseHeaders.set('access-control-allow-origin', allowOrigin); if (preFlightRequest) { responseHeaders.set('access-control-allow-methods', allowMethods.join(',')); } if (preFlightRequest && allowHeaders !== undefined && allowHeaders.length > 0) { responseHeaders.set('access-control-allow-headers', allowHeaders.join(', ')); } const exposeHeaders = config.headers?.expose; if (exposeHeaders && exposeHeaders.length > 0) { responseHeaders.set('access-control-expose-headers', exposeHeaders.join(', ')); } if (config.credentials?.allow) { responseHeaders.set('access-control-allow-credentials', 'true'); } if (config.privateNetwork?.allow && request.headers.one('access-control-request-private-network') === 'true') { responseHeaders.set('access-control-allow-private-network', 'true'); } // no max-age support :) return true; } const ALL = '*'; const DEFAULT_METHODS = ['GET', 'HEAD']; function validateAllowCredentials(config: CorsConfig) { if (config.credentials?.allow === true && config.origins?.allow === ALL) { throw new Error(`when credentials.allow is true origins.allow cannot be "*"`); } } function validateAllowPrivateNetwork(config: CorsConfig) { if (config.privateNetwork?.allow === true && config.origins?.allow === ALL) { throw new Error(`when privateNetwork.allow is true origins.allow cannot be "*"`); } } function checkOrigin(config: CorsConfig, origin? :string): string | undefined { if (origin) { const allowedOrigins = config.origins?.allow; if (allowedOrigins) { if (allowedOrigins === ALL) { validateAllowCredentials(config); validateAllowPrivateNetwork(config); return ALL; } const originToCheck = trimTrailingSlash(origin.toLowerCase()); for (const allowedOrigin of allowedOrigins) { if ((allowedOrigin === ALL) || IOGateway.Filtering.valueMatches(allowedOrigin, originToCheck)) { return origin; } } } } } function checkMethods(config: CorsConfig, requestMethod?: string): string[] | undefined { if (requestMethod) { const allowedMethods = config.methods?.allow ?? DEFAULT_METHODS; if (allowedMethods === ALL) { return [requestMethod]; } if (IOGateway.Filtering.valuesMatch(allowedMethods, requestMethod)) { return allowedMethods; } } } function checkHeaders(config: CorsConfig, requestHeaders?: string[]): string[] | undefined { if (requestHeaders === undefined) { return; } if (requestHeaders.length == 0) { return []; } const allowedHeaders = config.headers?.allow; if (allowedHeaders === undefined) { return; } const allowAnyHeader = allowedHeaders === ALL; const result: string[] = []; for (const requestHeader of requestHeaders) { const value = requestHeader?.trim(); if (value) { if (allowAnyHeader) { result.push(value); } else { for (const allowedHeader of allowedHeaders) { if (value.toLowerCase() == allowedHeader) { result.push(value); break; } } } } } if (result.length > 0) { return result; } } function trimTrailingSlash(origin: string): string { return origin.endsWith('/') ? origin.slice(0, -1) : origin; } function getMethodToUse(request: HttpRequest<ReadonlyHttpHeaders>, isPreFlight: boolean): string | undefined { return (isPreFlight ? request.headers.one('access-control-request-method') : request.method); } function getHeadersToUse(request: HttpRequest, isPreFlight: boolean): string[] { const headers = request.headers; return (isPreFlight ? headers.list('access-control-request-headers') : Array.from(headers.keys())); }