UNPKG

mentoss

Version:

A utility to mock fetch requests and responses.

561 lines (560 loc) 21.4 kB
/** * @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); } }