mentoss
Version: 
A utility to mock fetch requests and responses.
561 lines (560 loc) • 21.4 kB
JavaScript
/**
 * @fileoverview CORS utilities for Fetch API requests.
 * @author Nicholas C. Zakas
 */
/* global Headers, ReadableStream */
//-----------------------------------------------------------------------------
// Data
//-----------------------------------------------------------------------------
// the methods allowed for simple requests
export const safeMethods = new Set(["GET", "HEAD", "POST"]);
// the headers that are always safe for CORS requests
export const alwaysSafeRequestHeaders = new Set([
    "accept",
    "accept-language",
    "content-language",
]);
// the headers allowed for simple requests
export const safeRequestHeaders = new Set([
    "accept",
    "accept-language",
    "content-language",
    "content-type",
    "range",
]);
// the headers that are forbidden to be sent with requests
export const forbiddenRequestHeaders = new Set([
    "accept-charset",
    "accept-encoding",
    "access-control-request-headers",
    "access-control-request-method",
    "connection",
    "content-length",
    "cookie",
    "cookie2",
    "date",
    "dnt",
    "expect",
    "host",
    "keep-alive",
    "origin",
    "referer",
    "te",
    "trailer",
    "transfer-encoding",
    "upgrade",
    "user-agent",
    "via",
]);
// the headers that can be used to override the method
const methodOverrideRequestHeaders = new Set([
    "x-http-method",
    "x-http-method-override",
    "x-method-override",
]);
// the headers that are always allowed to be read from responses
export const safeResponseHeaders = new Set([
    "cache-control",
    "content-language",
    "content-type",
    "expires",
    "last-modified",
    "pragma",
]);
// the headers that are forbidden to be read from responses
export const forbiddenResponseHeaders = new Set(["set-cookie", "set-cookie2"]);
// the content types allowed for simple requests
const simpleRequestContentTypes = new Set([
    "application/x-www-form-urlencoded",
    "multipart/form-data",
    "text/plain",
]);
const noCorsSafeHeaders = new Set([
    "accept",
    "accept-language",
    "content-language",
    "content-type",
]);
// the methods that are forbidden to be used with CORS
export const forbiddenMethods = new Set(["CONNECT", "TRACE", "TRACK"]);
export const CORS_ALLOW_ORIGIN = "Access-Control-Allow-Origin";
export const CORS_ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials";
export const CORS_EXPOSE_HEADERS = "Access-Control-Expose-Headers";
export const CORS_ALLOW_METHODS = "Access-Control-Allow-Methods";
export const CORS_ALLOW_HEADERS = "Access-Control-Allow-Headers";
export const CORS_MAX_AGE = "Access-Control-Max-Age";
export const CORS_REQUEST_METHOD = "Access-Control-Request-Method";
export const CORS_REQUEST_HEADERS = "Access-Control-Request-Headers";
export const CORS_ORIGIN = "Origin";
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
 * Checks if a method is forbidden for CORS.
 * @param {string} header The header to check.
 * @param {string} value The value to check.
 * @returns {boolean} `true` if the method is forbidden, `false` otherwise.
 * @see https://fetch.spec.whatwg.org/#forbidden-method
 */
function isForbiddenMethodOverride(header, value) {
    return (methodOverrideRequestHeaders.has(header) &&
        forbiddenMethods.has(value.toUpperCase()));
}
/**
 * Checks if a request header is forbidden for CORS.
 * @param {string} header The header to check.
 * @param {string} value The value to check.
 * @returns {boolean} `true` if the header is forbidden, `false` otherwise.
 * @see https://fetch.spec.whatwg.org/#forbidden-header-name
 */
function isForbiddenRequestHeader(header, value) {
    return (forbiddenRequestHeaders.has(header) ||
        header.startsWith("proxy-") ||
        header.startsWith("sec-") ||
        isForbiddenMethodOverride(header, value));
}
/**
 * Checks if a Range header value is a simple range according to the Fetch API spec.
 * @see https://fetch.spec.whatwg.org/#http-headers
 * @param {string} range The range value to check.
 * @returns {boolean} `true` if the range is a simple range, `false` otherwise.
 */
function isSimpleRangeHeader(range) {
    // range must start with "bytes="
    if (!range.startsWith("bytes=")) {
        return false;
    }
    const ranges = range.slice(6).split(",");
    // only one range is allowed
    if (ranges.length > 1) {
        return false;
    }
    // range should be in the format 0-255, -255, or 0-
    const rangeParts = ranges[0].split("-");
    if (rangeParts.length > 2) {
        return false;
    }
    const firstIsNumber = /^\d+/.test(rangeParts[0]);
    const secondIsNumber = /^\d+/.test(rangeParts[1]);
    // if the first part is missing, the second must be a number
    if (rangeParts[0] === "") {
        return secondIsNumber;
    }
    // if the second part is missing, the first must be a number
    if (rangeParts[1] === "") {
        return firstIsNumber;
    }
    // if both parts are present, they must both be numbers
    return firstIsNumber && secondIsNumber;
}
/**
 * Checks if a string contains any CORS-unsafe request-header bytes.
 * @param {string} str The string to check.
 * @returns {boolean} `true` if the string contains CORS-unsafe bytes, `false` otherwise.
 * @see https://fetch.spec.whatwg.org/#cors-unsafe-request-header-byte
 */
function containsCorsUnsafeRequestHeaderByte(str) {
    const unsafeBytePattern = 
    // eslint-disable-next-line no-control-regex
    /[\x00-\x08\x0A-\x1F\x22\x28\x29\x3A\x3C\x3E\x3F\x40\x5B\x5C\x5D\x7B\x7D\x7F]/u;
    return unsafeBytePattern.test(str);
}
/**
 * Checks if a request header is safe to be used with "no-cors" mode.
 * @param {string} name The name of the header.
 * @param {string} value The value of the header.
 * @returns {boolean} `true` if the header is safe, `false` otherwise.
 * @see https://fetch.spec.whatwg.org/#no-cors-safelisted-request-header-name
 */
function isNoCorsSafeListedRequestHeader(name, value) {
    if (!noCorsSafeHeaders.has(name.toLowerCase())) {
        return false;
    }
    return isCorsSafeListedRequestHeader(name, value);
}
/**
 * Checks if a request header is safe to be used with CORS.
 * @param {string} name The name of the header.
 * @param {string} value The value of the header.
 * @returns {boolean} `true` if the header is safe, `false` otherwise.
 * @see https://fetch.spec.whatwg.org/#cors-safelisted-request-header
 */
function isCorsSafeListedRequestHeader(name, value) {
    if (value.length > 128) {
        return false;
    }
    const hasUnsafeByte = containsCorsUnsafeRequestHeaderByte(value);
    switch (name.toLowerCase()) {
        case "accept":
            return !hasUnsafeByte;
        case "accept-language":
        case "content-language":
            return !/[^0-9A-Za-z *,\-.=;]/.test(value);
        case "content-type":
            if (hasUnsafeByte) {
                return false;
            }
            return simpleRequestContentTypes.has(value.toLowerCase());
        case "range":
            return isSimpleRangeHeader(value);
        default:
            return false;
    }
}
/**
 * Checks if a string is a valid origin.
 * @param {string} origin The origin to validate.
 * @returns {boolean} `true` if the origin is valid, `false` otherwise.
 */
function isValidOrigin(origin) {
    try {
        new URL(origin);
        return true;
    }
    catch {
        return false;
    }
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
 * Creates a CORS error object.
 * @param {string} requestUrl The URL of the request.
 * @param {string} origin The origin of the client making the request.
 * @param {string} message The error message.
 * @returns {TypeError} A TypeError with CORS error message.
 */
export function createCorsError(requestUrl, origin, message) {
    return new TypeError(`Access to fetch at '${requestUrl}' from origin '${origin}' has been blocked by CORS policy: ${message}`);
}
/**
 * Creates a CORS preflight error object.
 * @param {string} requestUrl The URL of the request.
 * @param {string} origin The origin of the client making the request.
 * @param {string} message The error message.
 * @returns {TypeError} A TypeError with CORS preflight error message.
 */
export function createCorsPreflightError(requestUrl, origin, message) {
    return createCorsError(requestUrl, origin, `Response to preflight request doesn't pass access control check: ${message}`);
}
/**
 * Asserts that the response has the correct CORS headers.
 * @param {Response} response The response to check.
 * @param {string} origin The origin to check against.
 * @param {boolean} isPreflight `true` if this is a preflight request, `false` otherwise.
 * @returns {void}
 * @throws {Error} When the response doesn't have the correct CORS headers.
 */
export function assertCorsResponse(response, origin, isPreflight = false) {
    const originHeader = response.headers.get(CORS_ALLOW_ORIGIN);
    const errorCreator = isPreflight
        ? createCorsPreflightError
        : createCorsError;
    if (!originHeader) {
        throw errorCreator(response.url, origin, "No 'Access-Control-Allow-Origin' header is present on the requested resource.");
    }
    // multiple values are not allowed
    if (originHeader.includes(",")) {
        throw errorCreator(response.url, origin, `The 'Access-Control-Allow-Origin' header contains multiple values '${originHeader}', but only one is allowed.`);
    }
    if (originHeader !== "*") {
        // must be a valid origin
        if (!isValidOrigin(origin)) {
            throw errorCreator(response.url, origin, `The 'Access-Control-Allow-Origin' header contains the invalid value '${originHeader}'.`);
        }
        const originUrl = new URL(origin);
        if (originUrl.origin !== originHeader) {
            throw errorCreator(response.url, origin, `The 'Access-Control-Allow-Origin' header has a value '${originHeader}' that is not equal to the supplied origin.`);
        }
    }
}
/**
 * Asserts that the response has the correct CORS headers for credentials.
 * @param {Response} response The response to check.
 * @param {string} origin The origin to check against.
 * @returns {void}
 * @throws {CorsError} When the response doesn't have the correct CORS headers for credentials.
 * @see https://fetch.spec.whatwg.org/#http-headers
 */
export function assertCorsCredentials(response, origin) {
    const allowCredentials = response.headers.get(CORS_ALLOW_CREDENTIALS);
    if (!allowCredentials) {
        throw createCorsError(response.url, origin, "No 'Access-Control-Allow-Credentials' header is present on the requested resource.");
    }
    if (allowCredentials !== "true") {
        throw createCorsError(response.url, origin, "The 'Access-Control-Allow-Credentials' header has a value that is not 'true'.");
    }
    if (response.headers.get(CORS_ALLOW_ORIGIN) === "*") {
        throw createCorsError(response.url, origin, "The 'Access-Control-Allow-Origin' header has a value of '*' which is not allowed when the 'Access-Control-Allow-Credentials' header is 'true'.");
    }
    if (response.headers.get(CORS_ALLOW_HEADERS) === "*") {
        throw createCorsError(response.url, origin, "The 'Access-Control-Allow-Headers' header has a value of '*' which is not allowed when the 'Access-Control-Allow-Credentials' header is 'true'.");
    }
    if (response.headers.get(CORS_ALLOW_METHODS) === "*") {
        throw createCorsError(response.url, origin, "The 'Access-Control-Allow-Methods' header has a value of '*' which is not allowed when the 'Access-Control-Allow-Credentials' header is 'true'.");
    }
    if (response.headers.get(CORS_EXPOSE_HEADERS) === "*") {
        throw createCorsError(response.url, origin, "The 'Access-Control-Expose-Headers' header has a value of '*' which is not allowed when the 'Access-Control-Allow-Credentials' header is 'true'.");
    }
}
/**
 * Asserts that a request is valid for "no-cors" mode.
 * @param {RequestInit} requestInit The request to check.
 * @returns {void}
 * @throws {TypeError} When the request is not valid for "no-cors" mode.
 */
export function assertValidNoCorsRequestInit(requestInit = {}) {
    const headers = requestInit.headers;
    const method = requestInit.method;
    // no method means GET
    if (!method && !headers) {
        return;
    }
    // otherwise check it
    if (method && !safeMethods.has(method)) {
        throw new TypeError(`Method '${method}' is not allowed in 'no-cors' mode.`);
    }
    // no headers means nothing to check
    if (!headers) {
        return;
    }
    const headerKeyValues = Array.from(headers instanceof Headers
        ? headers.entries()
        : Object.entries(headers));
    for (const [header, value] of headerKeyValues) {
        if (!isNoCorsSafeListedRequestHeader(header, value)) {
            throw new TypeError(`Header '${header}' is not allowed in 'no-cors' mode.`);
        }
    }
}
/**
 * Processes a CORS response to ensure it's valid and doesn't contain
 * any forbidden headers.
 * @param {Response} response The response to process.
 * @param {string} origin The origin of the request.
 * @param {boolean} useCorsCredentials `true` if credentials are used, `false` otherwise.
 * @returns {Response} The processed response.
 */
export function processCorsResponse(response, origin, useCorsCredentials) {
    // first check that the response is allowed
    assertCorsResponse(response, origin);
    // check credentials
    if (useCorsCredentials) {
        assertCorsCredentials(response, origin);
    }
    // check if the Access-Control-Expose-Headers header is present
    const exposedHeaders = response.headers.get(CORS_EXPOSE_HEADERS);
    const allowedHeaders = exposedHeaders
        ? new Set(exposedHeaders.toLowerCase().split(", "))
        : new Set();
    // next filter out any headers that aren't allowed
    for (const key of response.headers.keys()) {
        // first check if the header is always allowed
        if (safeResponseHeaders.has(key)) {
            continue;
        }
        // next check if the header is never allowed
        if (forbiddenResponseHeaders.has(key)) {
            response.headers.delete(key);
            continue;
        }
        // finally check if the header is allowed by the server
        if (!allowedHeaders.has(key)) {
            response.headers.delete(key);
        }
    }
    return response;
}
/**
 * Determines if a request is a simple CORS request.
 * @param {Request} request The request to check.
 * @returns {boolean} `true` if the request is a simple CORS request, `false` otherwise.
 */
export function isCorsSimpleRequest(request) {
    // if it's not a simple method then it's not a simple request
    if (!safeMethods.has(request.method)) {
        return false;
    }
    // ReadableStream is not allowed
    if (request.body && request.body instanceof ReadableStream) {
        return false;
    }
    // check all headers to ensure they're allowed
    const headers = request.headers;
    for (const header of headers.keys()) {
        if (!safeRequestHeaders.has(header)) {
            return false;
        }
    }
    // check the content type
    const contentType = headers.get("content-type");
    if (contentType && !simpleRequestContentTypes.has(contentType)) {
        return false;
    }
    // check the Range header
    const range = headers.get("range");
    if (range && !isSimpleRangeHeader(range)) {
        return false;
    }
    return true;
}
/**
 * Validates a CORS request.
 * @param {Request} request The request to validate.
 * @param {string} origin The origin of the request.
 * @returns {void}
 * @throws {CorsError} When the request is not allowed.
 */
export function validateCorsRequest(request, origin) {
    // check the method
    if (forbiddenMethods.has(request.method)) {
        throw createCorsError(request.url, origin, `Method ${request.method} is not allowed.`);
    }
    // check the headers
    for (const header of request.headers.keys()) {
        const value = /** @type {string} */ (request.headers.get(header));
        if (isForbiddenRequestHeader(header, value)) {
            throw createCorsError(request.url, origin, `Header ${header} is not allowed.`);
        }
    }
}
/**
 * Gets an array of headers that are not allowed in a CORS simple request.
 * @param {Request} request The request to check.
 * @returns {string[]} Array of header names that are not simple headers.
 */
export function getUnsafeHeaders(request) {
    const result = [];
    const headers = request.headers;
    for (const header of headers.keys()) {
        // Range header needs special validation
        if (header === "range") {
            const rangeValue = headers.get(header);
            if (rangeValue && !isSimpleRangeHeader(rangeValue)) {
                result.push(header);
            }
            continue;
        }
        // Content-Type header needs special validation
        if (header === "content-type") {
            const contentType = headers.get(header);
            if (contentType && !simpleRequestContentTypes.has(contentType)) {
                result.push(header);
            }
            continue;
        }
        // Check if header is in the safe list
        if (!safeRequestHeaders.has(header)) {
            result.push(header);
        }
    }
    return result;
}
/**
 * A class for storing CORS preflight data.
 */
export class CorsPreflightData {
    /**
     * The allowed methods for this URL.
     * @type {Set<string>}
     */
    allowedMethods = new Set();
    /**
     * Whether all methods are allowed for this URL.
     * @type {boolean}
     */
    allowAllMethods = false;
    /**
     * The allowed headers for this URL.
     * @type {Set<string>}
     **/
    allowedHeaders = new Set();
    /**
     * Whether all headers are allowed for this URL.
     * @type {boolean}
     */
    allowAllHeaders = false;
    /**
     * Whether credentials are allowed for this URL.
     * @type {boolean}
     */
    allowCredentials = false;
    /**
     * The maximum age for this URL.
     * @type {number}
     */
    maxAge;
    /**
     * Creates a new instance.
     * @param {Headers} headers The headers to use.
     */
    constructor(headers) {
        const allowMethods = headers.get(CORS_ALLOW_METHODS);
        if (allowMethods) {
            this.allowedMethods = new Set(allowMethods.toUpperCase().split(", "));
            this.allowAllMethods = this.allowedMethods.has("*");
        }
        const allowHeaders = headers.get(CORS_ALLOW_HEADERS);
        if (allowHeaders) {
            this.allowedHeaders = new Set(allowHeaders.toLowerCase().split(", "));
            this.allowAllHeaders = this.allowedHeaders.has("*");
        }
        this.allowCredentials = headers.get(CORS_ALLOW_CREDENTIALS) === "true";
        this.maxAge = Number(headers.get(CORS_MAX_AGE)) || Infinity;
        // Note: Access-Control-Expose-Headers is not honored on preflight requests
    }
    /**
     * Validates a method against the preflight data.
     * @param {Request} request The request with the method to validate.
     * @param {string} origin The origin of the request.
     * @returns {void}
     * @throws {Error} When the method is not allowed.
     */
    #validateMethod(request, origin) {
        const method = request.method.toUpperCase();
        if (!this.allowAllMethods &&
            !safeMethods.has(method) &&
            !this.allowedMethods.has(method)) {
            throw createCorsError(request.url, origin, `Method ${method} is not allowed.`);
        }
    }
    /**
     * Validates a set of headers against the preflight data.
     * @param {Request} request The request with headers to validate.
     * @param {string} origin The origin of the request.
     * @returns {void}
     * @throws {Error} When the headers are not allowed.
     */
    #validateHeaders(request, origin) {
        const { headers } = request;
        const unsafeHeaders = new Set(getUnsafeHeaders(request));
        for (const header of headers.keys()) {
            // simple headers are always allowed
            if (alwaysSafeRequestHeaders.has(header)) {
                continue;
            }
            // Authorization is only allowed if explicitly mentioned
            if (header === "authorization" &&
                !this.allowedHeaders.has(header)) {
                throw createCorsError(request.url, origin, `Header ${header} is not allowed.`);
            }
            if (unsafeHeaders.has(header) &&
                !this.allowAllHeaders &&
                !this.allowedHeaders.has(header)) {
                throw createCorsError(request.url, origin, `Header ${header} is not allowed.`);
            }
        }
    }
    /**
     * Validates a request against the preflight data.
     * @param {Request} request The request to validate.
     * @param {string} origin The origin of the request.
     * @returns {void}
     * @throws {Error} When the request is not allowed.
     */
    validate(request, origin) {
        this.#validateMethod(request, origin);
        this.#validateHeaders(request, origin);
    }
}