@interopio/gateway-server
Version:
## `@glue42/gateway-ent` compatibility
312 lines (271 loc) • 10 kB
text/typescript
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()));
}